Merge remote-tracking branch 'origin/main' into brennanb2025/fix-mobile-terminal-blank

This commit is contained in:
Brennan Benson
2026-07-14 22:02:58 -07:00
192 changed files with 12265 additions and 971 deletions
@@ -9,6 +9,8 @@ on:
- 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
- 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
File diff suppressed because one or more lines are too long
@@ -7,6 +7,19 @@ const projectDir = resolve(import.meta.dirname, '../..')
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
describe('Electron runtime package contract', () => {
it('keeps shared WebGL atlas invalidation reproducible from vendored source', () => {
const patch = readFileSync(
join(projectDir, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
'utf8'
)
expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
expect(patch).toContain('readonly clearModelGeneration: number')
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
expect(patch).toContain('this.clearModelGeneration++')
expect(patch).toContain('this._atlas._clearModelGeneration||0')
})
it('keeps root postinstall as the single Electron binary install owner', () => {
expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs')
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron')
@@ -535,6 +548,9 @@ describe('Electron runtime package contract', () => {
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
'terminal-raw-emoji-table-scroll-restore.spec.ts'
)
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
'terminal-webgl-atlas-budget.spec.ts'
)
expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
'terminal-long-table-scroll-restore.spec.ts'
)
@@ -548,6 +564,8 @@ describe('Electron runtime package contract', () => {
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
}
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
expect(pullRequestPaths).toContain('tests/e2e/terminal-webgl-atlas-budget.spec.ts')
expect(pullRequestPaths).toContain('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.3m">
<title>downloads: 5.3m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.4m">
<title>downloads: 5.4m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.3m</text>
<text x="93.5" y="14">5.3m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.4m</text>
<text x="93.5" y="14">5.4m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+1 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.27",
"version": "0.0.30",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
+4
View File
@@ -178,6 +178,10 @@ export default function RootLayout() {
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="notification-opt-in"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
+83 -64
View File
@@ -184,6 +184,7 @@ import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-n
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import {
@@ -197,8 +198,14 @@ import {
isDictationSetupRequiredError
} from '../../../../src/dictation/mobile-dictation-setup'
import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
import {
activateMobileSessionTab,
focusMobileTerminal
} from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import {
getRepoIdFromMobileWorktreeId,
getActiveTabIdForHandle,
isFileExistsErrorMessage,
isGestureMouseTrackingMode,
MOBILE_SESSION_STATUS_LABELS,
@@ -206,7 +213,8 @@ import {
TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS,
TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES,
TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS,
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
@@ -245,21 +253,6 @@ type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryI
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function getActiveTabIdForHandle(
tabs: MobileSessionTab[],
terminalHandle: string | null
): string | null {
if (!terminalHandle) {
return null
}
return (
tabs.find(
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
tab.type === 'terminal' && tab.terminal === terminalHandle
)?.id ?? terminalHandle
)
}
function MarkdownReader({
documentId,
doc,
@@ -800,21 +793,6 @@ function FileReader({
return renderSourceText(doc.content)
}
function updateTerminalCwdFromStreamEvent(
handle: string,
data: Record<string, unknown>,
terminalCwd: Map<string, string>
): void {
if (!('cwd' in data)) {
return
}
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
terminalCwd.set(handle, data.cwd)
return
}
terminalCwd.delete(handle)
}
export default function SessionScreen() {
const {
hostId,
@@ -1031,6 +1009,7 @@ export default function SessionScreen() {
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
const subscribingHandlesRef = useRef<Set<string>>(new Set())
const initializedHandlesRef = useRef<Set<string>>(new Set())
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
// Why: WebViews load xterm.js from CDN asynchronously. Hidden WebViews
// (opacity:0) may have delayed JS execution on iOS. We must not subscribe
// until the WebView has fired web-ready, otherwise init() messages queue
@@ -1315,6 +1294,7 @@ export default function SessionScreen() {
terminalUnsubsRef.current.get(handle)?.()
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: a fresh subscription will land on a new server-side state machine
// run (or the same one with a higher seq); reset the high-water mark so
@@ -1329,6 +1309,7 @@ export default function SessionScreen() {
terminalUnsubsRef.current.clear()
subscribingHandlesRef.current.clear()
initializedHandlesRef.current.clear()
terminalDiagnosticsRef.current.clearTerminalCache()
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
@@ -1350,6 +1331,7 @@ export default function SessionScreen() {
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
terminalDiagnosticsRef.current.viewportMeasured(handle, dims, terminalFrameHeightRef.current)
if (dims) {
viewportRef.current = dims
viewportMeasuredRef.current = true
@@ -1360,25 +1342,34 @@ export default function SessionScreen() {
const subscribeToTerminal = useCallback(
(handle: string) => {
const diagnostics = terminalDiagnosticsRef.current
const logSkippedGate = (reason: string) =>
diagnostics.streamSkipped(handle, reason, handle === activeHandleRef.current)
if (!client) {
logSkippedGate('no-client')
return
}
if (terminalUnsubsRef.current.has(handle)) {
logSkippedGate('already-subscribed')
return
}
if (subscribingHandlesRef.current.has(handle)) {
logSkippedGate('subscribe-in-flight')
return
}
if (!getTerminalRef(handle)) {
logSkippedGate('no-webview-ref')
return
}
if (!webReadyHandlesRef.current.has(handle)) {
logSkippedGate('webview-not-ready')
return
}
subscribingHandlesRef.current.add(handle)
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
subscribeSeqRef.current.set(handle, seq)
diagnostics.streamArmed(handle, seq, viewportRef.current)
// Why: server handles auto-fit on subscribe — no terminal.focus call needed.
// The viewport is embedded in the subscribe params so the server resizes
@@ -1397,6 +1388,7 @@ export default function SessionScreen() {
return
}
const data = result as Record<string, unknown>
diagnostics.firstStreamEvent(handle, seq, data.type)
// Why: stale-event filter. Server-side state machine bumps a
// monotonic seq on every applyLayout. Drop `resized` events
// whose seq is strictly older than what we've already observed
@@ -1429,6 +1421,7 @@ export default function SessionScreen() {
return
}
if (data.type === 'scrollback') {
diagnostics.streamScrollback(handle, seq, eventSeq, data)
if (initializedHandlesRef.current.has(handle)) {
return
}
@@ -1520,6 +1513,7 @@ export default function SessionScreen() {
// server still has a null viewport for THIS subscriber
// record — we MUST resubscribe so the server stores it.
if (dims) {
diagnostics.streamResubscribing(handle, seq, dims)
viewportRef.current = dims
viewportMeasuredRef.current = true
unsubscribeTerminal(handle)
@@ -1564,6 +1558,7 @@ export default function SessionScreen() {
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const serialized = typeof data.serialized === 'string' ? data.serialized : null
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
if (serialized != null) {
getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks)
@@ -1733,6 +1728,7 @@ export default function SessionScreen() {
const applySessionTabs = useCallback(
(result: SessionTabsResult) => {
const diagnostics = terminalDiagnosticsRef.current
// Reject out-of-order snapshots, then suppress just-closed tabs until the
// publisher confirms their absence. See session-tab-snapshot-gate.
if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) {
@@ -1789,15 +1785,21 @@ export default function SessionScreen() {
const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current
const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current
let active = snapshotActive
let selectionSource = 'snapshot'
if (pendingActiveSessionTabId) {
if (snapshotActive?.id === pendingActiveSessionTabId) {
pendingActiveSessionTabIdRef.current = null
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveSessionTabIdRef.current = null
} else {
selectionSource = 'pending-tab-local-ack'
}
} else {
const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId)
if (pendingTab) {
// Why: desktop tab snapshots can lag a mobile tap while activate RPC
// is in flight. Keep the locally selected tab to avoid snapping back.
active = pendingTab
selectionSource = 'pending-tab'
} else {
pendingActiveSessionTabIdRef.current = null
}
@@ -1815,12 +1817,17 @@ export default function SessionScreen() {
snapshotActive?.type === 'terminal' &&
snapshotActive.terminal === pendingActiveTerminalHandle
) {
pendingActiveTerminalHandleRef.current = null
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveTerminalHandleRef.current = null
} else {
selectionSource = 'pending-handle-local-ack'
}
} else if (pendingTerminalTab) {
// Why: desktop active flags can lag a mobile terminal tap. Key by
// terminal handle too, because fallback PTY tabs may not yet have a
// stable session tab id during new-worktree startup.
active = pendingTerminalTab
selectionSource = 'pending-handle-tab'
} else if (pendingTerminalExists) {
const nextActiveTabId = getActiveTabIdForHandle(nextTabs, pendingActiveTerminalHandle)
activeSessionTabIdRef.current = nextActiveTabId
@@ -1833,6 +1840,7 @@ export default function SessionScreen() {
pendingActiveTerminalHandleRef.current = null
}
}
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
activeSessionTabTypeRef.current = active?.type ?? null
activeSessionTabIdRef.current = active?.id ?? null
setActiveSessionTabId(active?.id ?? null)
@@ -2377,20 +2385,25 @@ export default function SessionScreen() {
const fetchSessionTabs = useCallback(async () => {
if (!client) {
terminalDiagnosticsRef.current.tabsFetchSkipped('no-client')
return
}
if (fetchSessionTabsInFlightRef.current) {
terminalDiagnosticsRef.current.tabsFetchSkipped('already-in-flight')
return
}
fetchSessionTabsInFlightRef.current = true
terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId)
try {
const response = await client.sendRequest('session.tabs.list', {
worktree: `id:${worktreeId}`
})
if (!response.ok) {
terminalDiagnosticsRef.current.tabsFetchFailed((response as RpcFailure).error.code)
return
}
const result = (response as RpcSuccess).result as SessionTabsResult
terminalDiagnosticsRef.current.tabsFetchSucceeded(result)
applySessionTabs(result)
// Focus a just-opened browser tab once it appears in the snapshot, via the
// normal activate path so it sticks and the user can still switch away.
@@ -2404,7 +2417,8 @@ export default function SessionScreen() {
switchSessionTabRef.current?.(browserTab)
}
}
} catch {
} catch (error) {
terminalDiagnosticsRef.current.tabsFetchErrored(error)
// Keep the last tab snapshot visible during reconnect/backoff.
} finally {
fetchSessionTabsInFlightRef.current = false
@@ -2689,6 +2703,7 @@ export default function SessionScreen() {
pendingBrowserFocusPageIdRef.current = null
pendingTerminalActivationAttemptRef.current = null
initialEmptySessionAutoCreateRef.current = null
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
@@ -2912,6 +2927,7 @@ export default function SessionScreen() {
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
tab.type === 'terminal' && tab.terminal === handle
)
terminalDiagnosticsRef.current.tabSwitch('terminal', matchingTab?.id ?? '', false, handle)
pendingActiveSessionTabIdRef.current = matchingTab?.id ?? null
pendingActiveTerminalHandleRef.current = handle
activeSessionTabTypeRef.current = 'terminal'
@@ -2931,15 +2947,15 @@ export default function SessionScreen() {
}
subscribeToTerminal(handle)
if (client) {
void client.sendRequest('terminal.focus', { terminal: handle }).catch(() => {})
void focusMobileTerminal(client, handle).catch(() => {})
if (matchingTab) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false
})
.catch(() => {})
// Why: persist selection for headless hosts; the snapshot gate keeps
// this phone-local acknowledgement from impersonating desktop focus.
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false
}).catch(() => {})
}
}
},
@@ -2960,6 +2976,7 @@ export default function SessionScreen() {
switchTab(tab.terminal)
return
}
terminalDiagnosticsRef.current.tabSwitch('terminal', tab.id, true)
triggerSelection()
pendingActiveSessionTabIdRef.current = tab.id
pendingActiveTerminalHandleRef.current = null
@@ -2973,18 +2990,17 @@ export default function SessionScreen() {
activeHandleRef.current = null
setActiveHandle(null)
if (client) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
}).catch(() => {})
}
return
}
triggerSelection()
terminalDiagnosticsRef.current.tabSwitch(tab.type, tab.id, false)
pendingActiveSessionTabIdRef.current = tab.id
pendingActiveTerminalHandleRef.current = null
activeSessionTabTypeRef.current = tab.type
@@ -2997,13 +3013,11 @@ export default function SessionScreen() {
activeHandleRef.current = null
setActiveHandle(null)
if (client) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
}).catch(() => {})
}
if (tab.type === 'browser') {
return
@@ -3031,6 +3045,7 @@ export default function SessionScreen() {
// init messages. This prevents the blank terminal race where init() was
// queued before the WebView loaded.
const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => {
terminalDiagnosticsRef.current.webViewRef(handle, ref != null)
if (ref) {
terminalRefs.current.set(handle, ref)
} else {
@@ -3049,6 +3064,11 @@ export default function SessionScreen() {
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
webReadyHandlesRef.current.add(handle)
terminalDiagnosticsRef.current.webViewReady(
handle,
wasAlreadyReady,
handle === activeHandleRef.current
)
if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) {
// Why: the native WebView reloaded (Metro hot reload or Android
// process churn). The old xterm buffer is gone, so force a fresh
@@ -3085,7 +3105,7 @@ export default function SessionScreen() {
})()
}
},
[measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
[getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
)
useEffect(() => {
@@ -4302,13 +4322,12 @@ export default function SessionScreen() {
// Why: a hydrated headless/server-owned tab can already be active but still
// pending; activation is the RPC that materializes or focuses its PTY handle.
pendingTerminalActivationAttemptRef.current = activationKey
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false
})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false
})
.then((response) => {
if (!response.ok) {
if (pendingTerminalActivationAttemptRef.current === activationKey) {
+15 -4
View File
@@ -36,6 +36,7 @@ import {
} from '../src/transport/client-context'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
@@ -317,6 +318,7 @@ export default function HomeScreen() {
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
null
)
const notificationOptInCheckedRef = useRef(false)
// Why: read shared clients from the per-host store. Replaces the prior
// pattern of opening N independent WebSockets here. See
@@ -395,9 +397,18 @@ export default function HomeScreen() {
useFocusEffect(
useCallback(() => {
let stale = false
void loadHosts().then((h) => {
if (!stale) {
setHosts(h)
void loadHosts().then(async (h) => {
if (stale) {
return
}
setHosts(h)
if (h.length === 0 || notificationOptInCheckedRef.current) {
return
}
notificationOptInCheckedRef.current = true
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!stale && showNotificationOptIn) {
router.replace('/notification-opt-in')
}
})
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
@@ -419,7 +430,7 @@ export default function HomeScreen() {
return () => {
stale = true
}
}, [])
}, [router])
)
const sortedHosts = useMemo(
+236
View File
@@ -0,0 +1,236 @@
import { useCallback, useState } from 'react'
import {
ActivityIndicator,
BackHandler,
Pressable,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { SafeAreaView } from 'react-native-safe-area-context'
import { BellRing } from 'lucide-react-native'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
import { savePushNotificationsEnabled } from '../src/storage/preferences'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
export default function NotificationOptInScreen() {
const router = useRouter()
const params = useLocalSearchParams<{ hostId?: string | string[] }>()
const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId
const [busyChoice, setBusyChoice] = useState<'enable' | 'skip' | null>(null)
const [error, setError] = useState<string | null>(null)
// Why: this one-time screen requires an explicit Enable or Not now choice;
// disabling back gestures alone would still leave Android hardware back open.
useFocusEffect(
useCallback(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => true)
return () => subscription.remove()
}, [])
)
const continueToApp = useCallback(() => {
router.replace(hostId ? `/h/${hostId}` : '/')
}, [hostId, router])
const choose = useCallback(
async (choice: 'enable' | 'skip') => {
if (busyChoice) {
return
}
setBusyChoice(choice)
setError(null)
try {
const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false
await savePushNotificationsEnabled(enabled)
continueToApp()
} catch {
setError('Notification settings could not be updated. Try again.')
setBusyChoice(null)
}
},
[busyChoice, continueToApp]
)
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.brandRow}>
<OrcaLogo size={22} />
<Text style={styles.brandName}>Orca</Text>
</View>
<View style={styles.content}>
<View style={styles.iconSurface}>
<BellRing size={30} color={colors.textPrimary} />
</View>
<Text style={styles.eyebrow}>Notifications</Text>
<Text style={styles.title}>Stay updated while away</Text>
<Text style={styles.body}>
Get notified on this device when an agent needs your input or finishes a task.
</Text>
</View>
<View style={styles.footer}>
{error ? (
<Text style={styles.error} accessibilityRole="alert">
{error}
</Text>
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel="Enable agent notifications"
disabled={busyChoice !== null}
style={({ pressed }) => [
styles.primaryButton,
pressed && styles.buttonPressed,
busyChoice !== null && styles.buttonDisabled
]}
onPress={() => void choose('enable')}
>
{busyChoice === 'enable' ? (
<ActivityIndicator color={colors.bgBase} />
) : (
<Text style={styles.primaryButtonText}>Enable notifications</Text>
)}
</Pressable>
<Pressable
accessibilityRole="button"
disabled={busyChoice !== null}
style={({ pressed }) => [
styles.secondaryButton,
pressed && styles.buttonPressed,
busyChoice !== null && styles.buttonDisabled
]}
onPress={() => void choose('skip')}
>
{busyChoice === 'skip' ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Text style={styles.secondaryButtonText}>Not now</Text>
)}
</Pressable>
<Text style={styles.footerNote}>You can change this any time in Settings.</Text>
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.xl
},
// Why: this decision screen cannot be dismissed with Back, so every action
// must remain reachable in landscape and with accessibility text scaling.
scrollContent: {
flexGrow: 1
},
brandRow: {
minHeight: 52,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
brandName: {
color: colors.textPrimary,
fontSize: 17,
fontWeight: '700'
},
content: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xl
},
iconSurface: {
width: 64,
height: 64,
borderRadius: radii.card,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
marginBottom: spacing.xl
},
eyebrow: {
color: colors.textMuted,
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.55,
textTransform: 'uppercase',
marginBottom: spacing.sm
},
title: {
maxWidth: 420,
color: colors.textPrimary,
fontSize: 26,
fontWeight: '700',
letterSpacing: -0.3,
textAlign: 'center'
},
body: {
maxWidth: 420,
color: colors.textSecondary,
fontSize: typography.bodySize,
lineHeight: 21,
textAlign: 'center',
marginTop: spacing.md
},
footer: {
width: '100%',
maxWidth: 420,
alignSelf: 'center',
paddingBottom: spacing.lg
},
primaryButton: {
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.surfaceBright,
paddingVertical: spacing.sm
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
secondaryButton: {
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
marginTop: spacing.xs,
paddingVertical: spacing.sm
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
buttonPressed: {
opacity: 0.72
},
buttonDisabled: {
opacity: 0.58
},
footerNote: {
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: 18,
textAlign: 'center',
marginTop: spacing.sm
},
error: {
color: colors.statusRed,
fontSize: typography.metaSize,
lineHeight: 18,
textAlign: 'center',
marginBottom: spacing.sm
}
})
+4 -3
View File
@@ -17,7 +17,8 @@ import {
const DEFAULT_PERMISSION_STATE: NotificationPermissionState = {
granted: false,
status: 'undetermined',
canAskAgain: true
canAskAgain: true,
authorizationReflectsUserChoice: false
}
export default function NotificationsScreen() {
@@ -69,7 +70,7 @@ export default function NotificationsScreen() {
const notificationsBlocked = permissionState.status === 'denied'
const hint = notificationsBlocked
? 'Notifications are disabled in system settings.'
: 'Receive notifications when an agent task completes on your desktop.'
: 'Get notified on this device when an agent needs your input or finishes a task.'
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
@@ -82,7 +83,7 @@ export default function NotificationsScreen() {
<View style={styles.section}>
<View style={styles.row}>
<Text style={styles.rowLabel}>Push Notifications</Text>
<Text style={styles.rowLabel}>Agent notifications</Text>
<Switch
value={switchEnabled}
disabled={notificationsBlocked}
+10 -1
View File
@@ -11,6 +11,7 @@ import {
import type { ConnectionLogEntry } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
type Status = 'awaiting-confirm' | 'connecting' | 'error'
@@ -103,7 +104,15 @@ export default function PairConfirmScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
router.replace(`/h/${hostId}`)
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!mountedRef.current) {
return
}
router.replace(
showNotificationOptIn
? { pathname: '/notification-opt-in', params: { hostId } }
: `/h/${hostId}`
)
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
+10 -1
View File
@@ -21,6 +21,7 @@ import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
@@ -147,7 +148,15 @@ export default function PairScanScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
router.replace(`/h/${hostId}`)
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!mountedRef.current) {
return
}
router.replace(
showNotificationOptIn
? { pathname: '/notification-opt-in', params: { hostId } }
: `/h/${hostId}`
)
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import {
getNotificationPermissionState,
setScheduledNotificationsMaxForTests,
subscribeToDesktopNotifications
} from './mobile-notifications'
@@ -18,7 +20,7 @@ vi.mock('expo-notifications', () => ({
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' }
Platform: { OS: 'ios', Version: 18 }
}))
// Why: mobile-notifications now persists the catch-up watermark to
@@ -35,6 +37,32 @@ vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
beforeEach(() => {
Object.assign(Platform, { OS: 'ios', Version: 18 })
})
describe('getNotificationPermissionState', () => {
it.each([
{ os: 'android', version: 32, expected: false },
{ os: 'android', version: 33, expected: true },
{ os: 'ios', version: 18, expected: true }
])(
'reports whether a granted $os $version authorization reflects user choice',
async ({ os, version, expected }) => {
Object.assign(Platform, { OS: os, Version: version })
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
await expect(getNotificationPermissionState()).resolves.toMatchObject({
granted: true,
authorizationReflectsUserChoice: expected
})
}
)
})
describe('subscribeToDesktopNotifications', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -82,6 +82,7 @@ export type NotificationPermissionState = {
granted: boolean
status: string
canAskAgain: boolean
authorizationReflectsUserChoice: boolean
}
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
@@ -89,7 +90,11 @@ export async function getNotificationPermissionState(): Promise<NotificationPerm
return {
granted: status === 'granted',
status,
canAskAgain
canAskAgain,
// Why: Android before API 33 has no runtime notification permission, so
// Expo's default "granted" state is capability evidence, not user consent.
authorizationReflectsUserChoice:
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
}
}
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
readPushNotificationsPreference,
savePushNotificationsEnabled
} from '../storage/preferences'
import { getNotificationPermissionState } from './mobile-notifications'
import { shouldPresentNotificationOptIn } from './notification-opt-in-gate'
vi.mock('../storage/preferences', () => ({
readPushNotificationsPreference: vi.fn(),
savePushNotificationsEnabled: vi.fn()
}))
vi.mock('./mobile-notifications', () => ({
getNotificationPermissionState: vi.fn()
}))
describe('notification opt-in gate', () => {
beforeEach(() => {
vi.mocked(readPushNotificationsPreference).mockReset()
vi.mocked(savePushNotificationsEnabled).mockReset()
vi.mocked(getNotificationPermissionState).mockReset()
})
it('presents only when the local preference and system decision are both unset', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'undetermined',
canAskAgain: true,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
})
it.each([true, false])('preserves an existing %s mobile preference', async (value) => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true })
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(getNotificationPermissionState).not.toHaveBeenCalled()
})
it('adopts existing system authorization without prompting', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: true
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true)
})
it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
})
it('skips the gate when iOS has already denied permission', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'denied',
canAskAgain: false,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false)
})
it('does not block startup when storage or permission checks fail', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false })
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable'))
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
})
})
@@ -0,0 +1,36 @@
import {
readPushNotificationsPreference,
savePushNotificationsEnabled
} from '../storage/preferences'
import { getNotificationPermissionState } from './mobile-notifications'
export async function shouldPresentNotificationOptIn(): Promise<boolean> {
const preference = await readPushNotificationsPreference()
if (!preference.loaded || preference.value !== null) {
return false
}
try {
const permission = await getNotificationPermissionState()
if (permission.granted) {
if (!permission.authorizationReflectsUserChoice) {
return true
}
// Why: an already-authorized device should inherit the useful default
// without seeing an onboarding decision it has effectively made.
await savePushNotificationsEnabled(true)
return false
}
if (permission.status === 'denied' || !permission.canAskAgain) {
// Why: iOS cannot show its authorization prompt again, so a blocking
// onboarding screen would be a dead end; Settings remains the recovery.
await savePushNotificationsEnabled(false)
return false
}
return permission.status === 'undetermined'
} catch {
// Why: permission or persistence failures must not trap startup behind a
// decision screen whose result cannot be applied reliably.
return false
}
}
@@ -33,3 +33,31 @@ export function isGestureMouseTrackingMode(
): boolean {
return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'
}
export function getActiveTabIdForHandle(
tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>,
terminalHandle: string | null
): string | null {
if (!terminalHandle) {
return null
}
return (
tabs.find((tab) => tab.type === 'terminal' && tab.terminal === terminalHandle)?.id ??
terminalHandle
)
}
export function updateTerminalCwdFromStreamEvent(
handle: string,
data: Readonly<Record<string, unknown>>,
terminalCwd: Map<string, string>
): void {
if (!('cwd' in data)) {
return
}
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
terminalCwd.set(handle, data.cwd)
return
}
terminalCwd.delete(handle)
}
@@ -58,7 +58,7 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain(
'pendingTerminalActivationAttemptRef.current === activationKey'
)
expect(pendingActivationEffect).toContain("sendRequest('session.tabs.activate'")
expect(pendingActivationEffect).toContain('activateMobileSessionTab(client,')
expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id')
expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId')
expect(pendingActivationEffect).toContain('notifyClients: false')
@@ -68,8 +68,19 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()')
})
it('keeps mobile session tab activation local to the phone', () => {
const activationRequests = source.split("sendRequest('session.tabs.activate'").slice(1)
it('mirrors ready terminal taps while persisting them for headless hosts', () => {
const readyTerminalSwitch = sliceBetween(
'const switchTab = useCallback(',
'const switchSessionTab = useCallback('
)
expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)')
expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,')
expect(readyTerminalSwitch).toContain('notifyClients: false')
})
it('keeps background and pending session-tab activation local to the phone', () => {
const activationRequests = source.split('activateMobileSessionTab(client,').slice(1)
expect(activationRequests).toHaveLength(4)
for (const request of activationRequests) {
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation'
function success(): RpcResponse {
return { id: 'rpc-1', ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
}
function clientWith(sendRequest: RpcClient['sendRequest']): Pick<RpcClient, 'sendRequest'> {
return { sendRequest }
}
describe('mobile session tab activation', () => {
it('retries terminal focus once on the authenticated replacement after cutover', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(success())
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).resolves.toMatchObject(
{
ok: true
}
)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' })
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' })
})
it('retries session-tab activation with the same target after cutover', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(success())
const params = {
worktree: 'id:worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
notifyClients: false as const
}
await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({
ok: true
})
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.activate', params)
expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', params)
})
it('does not retry unrelated transport failures', async () => {
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockRejectedValue(new Error('offline'))
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).rejects.toThrow(
'offline'
)
expect(sendRequest).toHaveBeenCalledOnce()
})
it('retries at most once when consecutive cutovers interrupt activation', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValue(new LogicalClientCutoverError())
await expect(
activateMobileSessionTab(clientWith(sendRequest), {
worktree: 'id:worktree-1',
tabId: 'tab-1',
notifyClients: false
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
expect(sendRequest).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,90 @@
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcResponse } from '../transport/types'
import {
getMobileTerminalDiagnosticErrorName,
logMobileTerminalDiagnostic,
shortenMobileTerminalDiagnosticId
} from './mobile-terminal-diagnostics'
type ActivationClient = Pick<RpcClient, 'sendRequest'>
type MobileSessionTabActivationParams = {
worktree: string
tabId: string
leafId?: string
notifyClients: false
}
async function retryIdempotentActivationAfterCutover(
request: () => Promise<RpcResponse>,
operation: 'terminal.focus' | 'session.tabs.activate',
target: string
): Promise<RpcResponse> {
const diagnosticTarget = shortenMobileTerminalDiagnosticId(target)
logMobileTerminalDiagnostic('activation-request', { operation, target: diagnosticTarget })
try {
const response = await request()
logMobileTerminalDiagnostic('activation-result', {
operation,
target: diagnosticTarget,
ok: response.ok,
rpcCode: response.ok ? null : response.error.code
})
return response
} catch (error) {
if (!(error instanceof LogicalClientCutoverError)) {
logMobileTerminalDiagnostic('activation-error', {
operation,
target: diagnosticTarget,
errorName: getMobileTerminalDiagnosticErrorName(error)
})
throw error
}
logMobileTerminalDiagnostic('activation-cutover-retry', {
operation,
target: diagnosticTarget
})
// Why: cutover rejects ambiguous in-flight work after the replacement is
// active; these state-setting requests are idempotent and safe to repeat once.
try {
const response = await request()
logMobileTerminalDiagnostic('activation-result', {
operation,
target: diagnosticTarget,
ok: response.ok,
rpcCode: response.ok ? null : response.error.code
})
return response
} catch (retryError) {
logMobileTerminalDiagnostic('activation-error', {
operation,
target: diagnosticTarget,
errorName: getMobileTerminalDiagnosticErrorName(retryError)
})
throw retryError
}
}
}
export function focusMobileTerminal(
client: ActivationClient,
terminal: string
): Promise<RpcResponse> {
return retryIdempotentActivationAfterCutover(
() => client.sendRequest('terminal.focus', { terminal }),
'terminal.focus',
terminal
)
}
export function activateMobileSessionTab(
client: ActivationClient,
params: MobileSessionTabActivationParams
): Promise<RpcResponse> {
return retryIdempotentActivationAfterCutover(
() => client.sendRequest('session.tabs.activate', params),
'session.tabs.activate',
params.tabId
)
}
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import {
getMobileTerminalDiagnosticErrorName,
logMobileTerminalDiagnostic,
MobileTerminalDiagnostics,
shortenMobileTerminalDiagnosticId
} from './mobile-terminal-diagnostics'
describe('mobile terminal diagnostics', () => {
it('keeps only the correlatable suffix of identifiers', () => {
expect(shortenMobileTerminalDiagnosticId('terminal-secret-prefix-12345678')).toBe('12345678')
expect(shortenMobileTerminalDiagnosticId('short')).toBe('short')
expect(shortenMobileTerminalDiagnosticId(null)).toBeNull()
})
it('reports thrown error types without copying potentially sensitive messages', () => {
expect(getMobileTerminalDiagnosticErrorName(new TypeError('/private/worktree failed'))).toBe(
'TypeError'
)
expect(getMobileTerminalDiagnosticErrorName('raw failure')).toBe('string')
})
it('uses one filterable structured log tag', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
logMobileTerminalDiagnostic('stream-armed', { handle: '12345678', seq: 2 })
expect(log).toHaveBeenCalledWith('[terminal-diagnostic]', 'stream-armed', {
handle: '12345678',
seq: 2
})
log.mockRestore()
})
it('forgets first-event state when a terminal unsubscribes', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const diagnostics = new MobileTerminalDiagnostics()
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
diagnostics.terminalUnsubscribed('terminal-1')
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
expect(log).toHaveBeenCalledTimes(2)
log.mockRestore()
})
})
@@ -0,0 +1,291 @@
const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]'
type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined
export type MobileTerminalDiagnosticDetails = Readonly<
Record<string, MobileTerminalDiagnosticValue>
>
type DiagnosticTab = {
readonly id: string
readonly type: string
readonly isActive: boolean
readonly terminal?: string | null
}
type DiagnosticTabsSnapshot = {
readonly publicationEpoch?: string
readonly snapshotVersion: number
readonly tabs: readonly DiagnosticTab[]
}
type DiagnosticDimensions = { readonly cols: number; readonly rows: number } | null | undefined
// Why: full runtime identifiers make shared logs unnecessarily sensitive; the
// suffix is enough to correlate lifecycle events within one reproduction.
export function shortenMobileTerminalDiagnosticId(value: string | null | undefined): string | null {
if (!value) {
return null
}
return value.slice(-8)
}
export function getMobileTerminalDiagnosticErrorName(error: unknown): string {
if (error instanceof Error && error.name) {
return error.name
}
return typeof error
}
export function logMobileTerminalDiagnostic(
event: string,
details: MobileTerminalDiagnosticDetails = {}
): void {
// Why: lifecycle diagnostics are intentionally available for HMR repros,
// but high-frequency WebView events must not add production log overhead.
if (typeof __DEV__ !== 'undefined' && !__DEV__) {
return
}
// Keep this structured and content-free so users can safely share a filtered log.
console.log(MOBILE_TERMINAL_DIAGNOSTIC_TAG, event, details)
}
export class MobileTerminalDiagnostics {
private readonly streamGateByHandle = new Map<string, string>()
private readonly firstStreamEventSeqByHandle = new Map<string, number>()
private lastFetchedTabsSignature: string | null = null
private lastAppliedTabsSignature: string | null = null
private lastTabsFetchStartAt = 0
private tabsFetchSkipLogged = false
clearTerminalCache(): void {
this.streamGateByHandle.clear()
this.firstStreamEventSeqByHandle.clear()
}
resetRoute(): void {
this.clearTerminalCache()
this.lastFetchedTabsSignature = null
this.lastAppliedTabsSignature = null
this.lastTabsFetchStartAt = 0
this.tabsFetchSkipLogged = false
}
terminalUnsubscribed(handle: string): void {
this.streamGateByHandle.delete(handle)
this.firstStreamEventSeqByHandle.delete(handle)
}
viewportMeasured(handle: string, dims: DiagnosticDimensions, frameHeight: number): void {
logMobileTerminalDiagnostic('viewport-measure', {
handle: shortenMobileTerminalDiagnosticId(handle),
ok: dims != null,
cols: dims?.cols,
rows: dims?.rows,
frameHeight: Math.round(frameHeight)
})
}
streamSkipped(handle: string, reason: string, isActive: boolean): void {
if (this.streamGateByHandle.get(handle) === reason) {
return
}
this.streamGateByHandle.set(handle, reason)
logMobileTerminalDiagnostic('stream-skipped', {
handle: shortenMobileTerminalDiagnosticId(handle),
reason,
isActive
})
}
streamArmed(handle: string, seq: number, viewport: DiagnosticDimensions): void {
this.streamGateByHandle.delete(handle)
logMobileTerminalDiagnostic('stream-armed', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
hasViewport: viewport != null,
viewportCols: viewport?.cols,
viewportRows: viewport?.rows
})
}
firstStreamEvent(handle: string, seq: number, type: unknown): void {
if (this.firstStreamEventSeqByHandle.get(handle) === seq) {
return
}
this.firstStreamEventSeqByHandle.set(handle, seq)
logMobileTerminalDiagnostic('stream-first-event', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
type: typeof type === 'string' ? type : 'unknown'
})
}
streamScrollback(
handle: string,
seq: number,
eventSeq: number | null,
data: Readonly<Record<string, unknown>>
): void {
logMobileTerminalDiagnostic('stream-scrollback', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
eventSeq,
cols: typeof data.cols === 'number' ? data.cols : null,
rows: typeof data.rows === 'number' ? data.rows : null,
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
displayMode: typeof data.displayMode === 'string' ? data.displayMode : null,
source: typeof data.source === 'string' ? data.source : null,
scrollbackRows: typeof data.scrollbackRows === 'number' ? data.scrollbackRows : null,
truncated: data.truncated === true || data.truncatedByByteBudget === true
})
}
streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void {
logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
cols: dims.cols,
rows: dims.rows
})
}
streamResized(
handle: string,
seq: number,
eventSeq: number | null,
data: Readonly<Record<string, unknown>>,
hasRef: boolean
): void {
logMobileTerminalDiagnostic('stream-resized', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
eventSeq,
cols: typeof data.cols === 'number' ? data.cols : null,
rows: typeof data.rows === 'number' ? data.rows : null,
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
hasRef
})
}
tabsApplied(
snapshot: DiagnosticTabsSnapshot,
tabs: readonly DiagnosticTab[],
activeTab: DiagnosticTab | null,
selectionSource: string
): void {
const activeHandle =
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
? activeTab.terminal
: null
const appliedSnapshot = { ...snapshot, tabs }
const signature = [
appliedSnapshot.publicationEpoch ?? '',
appliedSnapshot.snapshotVersion,
activeTab?.id ?? '',
activeHandle ?? '',
selectionSource
].join(':')
if (this.lastAppliedTabsSignature === signature) {
return
}
this.lastAppliedTabsSignature = signature
this.logTabs('tabs-applied', appliedSnapshot, activeTab, activeHandle, { selectionSource })
}
tabsFetchSkipped(reason: string): void {
if (reason === 'already-in-flight' && this.tabsFetchSkipLogged) {
return
}
this.tabsFetchSkipLogged = reason === 'already-in-flight'
logMobileTerminalDiagnostic('tabs-fetch-skipped', { reason })
}
tabsFetchStarted(worktreeId: string): void {
this.tabsFetchSkipLogged = false
const now = Date.now()
if (this.lastFetchedTabsSignature != null && now - this.lastTabsFetchStartAt < 10_000) {
return
}
this.lastTabsFetchStartAt = now
logMobileTerminalDiagnostic('tabs-fetch-start', {
worktree: shortenMobileTerminalDiagnosticId(worktreeId)
})
}
tabsFetchFailed(rpcCode: string): void {
logMobileTerminalDiagnostic('tabs-fetch-rpc-failure', { rpcCode })
}
tabsFetchErrored(error: unknown): void {
logMobileTerminalDiagnostic('tabs-fetch-error', {
errorName: getMobileTerminalDiagnosticErrorName(error)
})
}
tabsFetchSucceeded(snapshot: DiagnosticTabsSnapshot): void {
const activeTab = snapshot.tabs.find((tab) => tab.isActive) ?? null
const signature = [
snapshot.publicationEpoch ?? '',
snapshot.snapshotVersion,
snapshot.tabs.length,
activeTab?.id ?? '',
activeTab?.type ?? ''
].join(':')
if (this.lastFetchedTabsSignature === signature) {
return
}
this.lastFetchedTabsSignature = signature
const activeHandle =
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
? activeTab.terminal
: null
this.logTabs('tabs-fetch-success', snapshot, activeTab, activeHandle)
}
tabSwitch(tabType: string, tabId: string, pending: boolean, handle?: string): void {
logMobileTerminalDiagnostic('tab-switch', {
tabType,
tab: shortenMobileTerminalDiagnosticId(tabId),
handle: shortenMobileTerminalDiagnosticId(handle),
pending
})
}
webViewRef(handle: string, attached: boolean): void {
logMobileTerminalDiagnostic('webview-ref', {
handle: shortenMobileTerminalDiagnosticId(handle),
attached
})
}
webViewReady(handle: string, reload: boolean, isActive: boolean): void {
logMobileTerminalDiagnostic('webview-ready', {
handle: shortenMobileTerminalDiagnosticId(handle),
reload,
isActive
})
}
private logTabs(
event: 'tabs-applied' | 'tabs-fetch-success',
snapshot: DiagnosticTabsSnapshot,
activeTab: DiagnosticTab | null,
activeHandle: string | null,
extra: MobileTerminalDiagnosticDetails = {}
): void {
logMobileTerminalDiagnostic(event, {
publication: shortenMobileTerminalDiagnosticId(snapshot.publicationEpoch),
snapshotVersion: snapshot.snapshotVersion,
tabCount: snapshot.tabs.length,
terminalTabCount: snapshot.tabs.filter((tab) => tab.type === 'terminal').length,
pendingTerminalCount: snapshot.tabs.filter(
(tab) => tab.type === 'terminal' && typeof tab.terminal !== 'string'
).length,
activeTab: shortenMobileTerminalDiagnosticId(activeTab?.id),
activeType: activeTab?.type ?? null,
activeHandle: shortenMobileTerminalDiagnosticId(activeHandle),
...extra
})
}
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from './session-tab-snapshot-gate'
@@ -50,6 +51,18 @@ describe('acceptSessionSnapshot', () => {
})
})
describe('confirmsMirroredTabSelection', () => {
it('does not treat phone-local persistence as desktop focus confirmation', () => {
expect(confirmsMirroredTabSelection('mobile-local:abc')).toBe(false)
})
it('accepts renderer, headless, and legacy publications as confirmation', () => {
expect(confirmsMirroredTabSelection('renderer:abc')).toBe(true)
expect(confirmsMirroredTabSelection('headless:abc')).toBe(true)
expect(confirmsMirroredTabSelection()).toBe(true)
})
})
describe('applyClosedTabTombstones', () => {
const tab = (id: string): { id: string } => ({ id })
@@ -33,6 +33,12 @@ export function acceptSessionSnapshot(
return true
}
export function confirmsMirroredTabSelection(publicationEpoch?: string): boolean {
// Why: phone-local persistence acknowledges the mutation, not host focus;
// keep the pending guard until any host publication confirms the selection.
return !publicationEpoch?.startsWith('mobile-local:')
}
/**
* Drops tabs the user just closed locally (tombstoned) until the publisher's
* snapshot also drops them or the tombstone expires. Mutates `tombstones`,
+43
View File
@@ -10,11 +10,14 @@ import {
clampHostSidebarWidth,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadPushNotificationsEnabled,
loadTerminalAutocompleteEnabled,
loadTerminalLinkOpenMode,
readPushNotificationsPreference,
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
savePushNotificationsEnabled,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
} from './preferences'
@@ -26,6 +29,46 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}
}))
describe('push notification preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
it('distinguishes an unset preference from an explicit disabled choice', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
await expect(readPushNotificationsPreference()).resolves.toEqual({
value: null,
loaded: true
})
await expect(loadPushNotificationsEnabled()).resolves.toBe(false)
vi.mocked(AsyncStorage.getItem).mockResolvedValue('false')
await expect(readPushNotificationsPreference()).resolves.toEqual({
value: false,
loaded: true
})
})
it('reports storage failures without enabling notifications', async () => {
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
await expect(readPushNotificationsPreference()).resolves.toEqual({
value: null,
loaded: false
})
await expect(loadPushNotificationsEnabled()).resolves.toBe(false)
})
it('persists the onboarding decision in the existing mobile toggle', async () => {
await savePushNotificationsEnabled(true)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'true')
await savePushNotificationsEnabled(false)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'false')
})
})
describe('terminal autocomplete preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
+17 -11
View File
@@ -3,23 +3,29 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
// Why: default-off so the iOS notification permission prompt never
// fires until the user explicitly opts in via Settings → Notifications.
// Apple's review guideline 4.5.4 and HIG both prefer user-initiated
// permission prompts; default-on would fire the prompt the moment the
// desktop sent its first notification, which can read as unsolicited.
export async function loadPushNotificationsEnabled(): Promise<boolean> {
export type PushNotificationsPreference = {
readonly value: boolean | null
readonly loaded: boolean
}
// Why: null distinguishes people who have never made the one-time onboarding
// decision from people who explicitly chose Not now or disabled notifications.
export async function readPushNotificationsPreference(): Promise<PushNotificationsPreference> {
try {
const raw = await AsyncStorage.getItem(NOTIF_KEY)
if (raw === null) {
return false
}
return raw === 'true'
return { value: raw === null ? null : raw === 'true', loaded: true }
} catch {
return false
return { value: null, loaded: false }
}
}
// Why: default-off prevents background notification events from opening the
// system prompt; only the onboarding CTA or Settings switch requests permission.
export async function loadPushNotificationsEnabled(): Promise<boolean> {
const preference = await readPushNotificationsPreference()
return preference.value ?? false
}
export async function savePushNotificationsEnabled(enabled: boolean): Promise<void> {
await AsyncStorage.setItem(NOTIF_KEY, String(enabled))
}
+7 -24
View File
@@ -6,6 +6,7 @@ import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-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'
@@ -217,6 +218,7 @@ window.onerror = function(msg) {
var statusDotPendingSelector = false;
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null; ${TERMINAL_QUERY_REPLY_JS}
${TERMINAL_SURFACE_SWAP_JS}
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
@@ -713,22 +715,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : [];
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = false;
var oldTerm = term;
var oldSurface = surface;
var nextSurface = null;
disposeTermObservers();
if (oldTerm) {
nextSurface = document.createElement('div');
nextSurface.id = 'terminal-surface';
nextSurface.style.visibility = 'hidden';
nextSurface.style.position = 'absolute';
nextSurface.style.left = '0';
nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(nextSurface);
surface = nextSurface;
attachSurfaceEventHandlers(surface);
oldSurface.removeAttribute('id');
}
var surfaceSwap = beginTerminalSurfaceSwap();
var nextSurface = surfaceSwap.nextSurface;
applyTerminalTheme(nextTheme);
term = new Terminal({
@@ -749,6 +737,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
convertEol: false,
allowProposedApi: true
});
var nextTerm = term;
pendingTerm = nextTerm;
term.open(surface);
attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
@@ -768,14 +758,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
everReady = true;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
if (nextSurface && oldSurface) {
nextSurface.style.visibility = 'visible';
nextSurface.style.position = '';
nextSurface.style.left = '';
nextSurface.style.top = '';
oldSurface.remove();
if (oldTerm) oldTerm.dispose();
}
commitTerminalSurfaceSwap(surfaceSwap, nextTerm);
// Why: restore the reader's place after the rewrapped buffer replays.
// Replay lands at bottom, so only act when they were scrolled up (rows>0).
if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) {
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
function iifeSource(): string {
const start = XTERM_HTML.indexOf('(function() {')
const end = XTERM_HTML.lastIndexOf('})();')
return XTERM_HTML.slice(start, end + '})();'.length)
}
function bodyMarkup(): string {
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
const end = XTERM_HTML.indexOf('<script>', start)
return XTERM_HTML.slice(start, end)
}
type TerminalStub = ReturnType<typeof makeTerminal>
function makeTerminal(writeCallbacks: Array<() => void>) {
const terminal = {
cols: 80,
rows: 24,
options: { fontSize: 13 },
modes: {},
element: null as HTMLElement | null,
disposed: false,
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } },
buffer: {
active: {
viewportY: 0,
baseY: 0,
length: 1,
cursorY: 0,
type: 'normal' as const,
getLine: () => null
}
},
write(_data: string, callback?: () => void) {
if (callback) {
writeCallbacks.push(callback)
}
},
open(surface: HTMLElement) {
terminal.element = surface
},
loadAddon() {},
resize(cols: number, rows: number) {
terminal.cols = cols
terminal.rows = rows
},
clear() {},
reset() {},
refresh() {},
selectAll() {},
clearSelection() {},
select() {},
scrollLines() {},
scrollToBottom() {},
scrollToLine() {},
getSelection: () => '',
onData: () => ({ dispose() {} }),
onLineFeed: () => ({ dispose() {} }),
onScroll: () => ({ dispose() {} }),
onWriteParsed: () => ({ dispose() {} }),
dispose() {
terminal.disposed = true
}
}
return terminal
}
function dispatchInit(cols: number, initialData: string): void {
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'init', cols, rows: 40, initialData })
})
)
}
describe('terminal WebView init surface replacement', () => {
let animationFrames: Array<() => void>
let terminals: TerminalStub[]
let writeCallbacks: Array<() => void>
beforeEach(() => {
animationFrames = []
terminals = []
writeCallbacks = []
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
animationFrames.push(callback)
return animationFrames.length
})
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
const webWindow = window as unknown as {
Terminal: new () => TerminalStub
ReactNativeWebView: { postMessage: (data: string) => void }
}
webWindow.Terminal = function () {
const terminal = makeTerminal(writeCallbacks)
terminals.push(terminal)
return terminal
} as unknown as new () => TerminalStub
webWindow.ReactNativeWebView = { postMessage: vi.fn() }
document.body.innerHTML = bodyMarkup()
// eslint-disable-next-line no-new-func
new Function(iifeSource())()
})
it('commits only the newest surface when phone-fit init calls overlap', () => {
// Why: restored terminals can receive desktop scrollback, a phone resize,
// and phone scrollback before any xterm replay callback has completed.
dispatchInit(120, 'desktop')
animationFrames.shift()?.()
dispatchInit(51, 'phone-resize')
animationFrames.shift()?.()
dispatchInit(51, 'phone-scrollback')
animationFrames.shift()?.()
expect(terminals).toHaveLength(3)
expect(writeCallbacks).toHaveLength(3)
writeCallbacks[2]?.()
writeCallbacks[0]?.()
writeCallbacks[1]?.()
const surfaces = document.querySelectorAll('#terminal-container > div')
expect(surfaces).toHaveLength(1)
expect(surfaces[0]?.id).toBe('terminal-surface')
expect((surfaces[0] as HTMLElement).style.visibility).toBe('visible')
expect(terminals.map((terminal) => terminal.disposed)).toEqual([true, true, false])
})
})
@@ -0,0 +1,49 @@
export const TERMINAL_SURFACE_SWAP_JS = String.raw`
// Why: phone-fit startup can issue several init() calls before xterm finishes
// replaying. Track the last painted surface separately from its replacement.
var committedTerm = null;
var committedSurface = surface;
var pendingTerm = null;
var pendingSurface = null;
function beginTerminalSurfaceSwap() {
// Why: a superseded hidden replacement must not remain between the last
// painted surface and the newest one, or the newest commits below the viewport.
if (pendingSurface) {
try { pendingSurface.remove(); } catch (e) {}
if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {}
pendingSurface = null;
pendingTerm = null;
}
var swap = {
oldTerm: committedTerm,
oldSurface: committedSurface,
nextSurface: document.createElement('div')
};
disposeTermObservers();
swap.nextSurface.id = 'terminal-surface';
swap.nextSurface.style.visibility = 'hidden';
swap.nextSurface.style.position = 'absolute';
swap.nextSurface.style.left = '0';
swap.nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(swap.nextSurface);
surface = swap.nextSurface;
pendingSurface = swap.nextSurface;
attachSurfaceEventHandlers(surface);
swap.oldSurface.removeAttribute('id');
return swap;
}
function commitTerminalSurfaceSwap(swap, nextTerm) {
swap.nextSurface.style.visibility = 'visible';
swap.nextSurface.style.position = '';
swap.nextSurface.style.left = '';
swap.nextSurface.style.top = '';
swap.oldSurface.remove();
if (swap.oldTerm) swap.oldTerm.dispose();
committedTerm = nextTerm;
committedSurface = swap.nextSurface;
pendingTerm = null;
pendingSurface = null;
}
`
@@ -130,7 +130,7 @@ describe('mobile relay pairing journal store', () => {
expect(metadataRaw).toBeNull()
})
it('cleans mismatched secret records and refuses to replace a recoverable journal', async () => {
it('cleans mismatched secret records', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
@@ -142,6 +142,137 @@ describe('mobile relay pairing journal store', () => {
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
expect(secretRaw).toBeNull()
})
it('replaces a journal that never selected an authorization path', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
const replacementSave = saveMobileRelayPairingJournal(replacement)
const staleUpdate = updateMobileRelayPairingJournal(
journal.metadata.journalId,
(metadata) => metadata
)
await expect(replacementSave).resolves.toBeUndefined()
await expect(staleUpdate).rejects.toThrow(/stale/)
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement)
})
it('serializes a replacement behind an in-flight journal snapshot', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
let releaseSecretRead!: () => void
const secretReadGate = new Promise<void>((resolve) => {
releaseSecretRead = resolve
})
secureStore.getItemAsync.mockImplementationOnce(async () => {
await secretReadGate
return secretRaw
})
const loading = loadMobileRelayPairingJournal()
await vi.waitFor(() => expect(secureStore.getItemAsync).toHaveBeenCalled())
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
let replacementSaved = false
const saving = saveMobileRelayPairingJournal(replacement).then(() => {
replacementSaved = true
})
await Promise.resolve()
expect(replacementSaved).toBe(false)
releaseSecretRead()
await expect(loading).resolves.toEqual(journal)
await expect(saving).resolves.toBeUndefined()
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement)
expect(asyncStorage.removeItem).not.toHaveBeenCalled()
})
it('cleans a replacement whose secret write fails', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable'))
await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/)
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
expect(secretRaw).toBeNull()
})
it('blocks replacement when the old authorization update wins the mutation race', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
const authorizationUpdate = updateMobileRelayPairingJournal(
journal.metadata.journalId,
(metadata) => ({
...metadata,
winner: 'direct',
authorizationMode: 'authenticated-direct'
})
)
const replacementSave = saveMobileRelayPairingJournal(replacement)
await expect(authorizationUpdate).resolves.toBeUndefined()
await expect(replacementSave).rejects.toThrow(/recovery pending/)
})
it.each([
['winner', { winner: 'direct' as const }],
['authorization mode', { authorizationMode: 'authenticated-direct' as const }]
])('refuses replacement once the durable %s exists', async (_name, authorization) => {
const created = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
const journal = {
...created,
metadata: { ...created.metadata, ...authorization }
}
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
@@ -27,9 +27,15 @@ export async function saveMobileRelayPairingJournal(
const mutation = journalMutation.then(async () => {
const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
const existing = existingRaw ? parseMetadata(existingRaw) : null
if (existing && existing.journalId !== metadata.journalId) {
if (
existing &&
existing.journalId !== metadata.journalId &&
(existing.winner !== undefined || existing.authorizationMode !== undefined)
) {
throw new Error('mobile relay pairing recovery pending')
}
// Why: no install RPC can run before winner+authorization are durable, so
// a new user-initiated scan may safely supersede a pre-authorization attempt.
// Why: metadata-first makes a crash before the keychain write recover as
// an incomplete journal, never as an untracked bearer secret.
await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata))
@@ -41,28 +47,36 @@ export async function saveMobileRelayPairingJournal(
export async function loadMobileRelayPairingJournal(): Promise<MobileRelayPairingJournal | null> {
requireNativeSecretStore()
await journalMutation
const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
if (rawMetadata === null) {
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
return null
}
const metadata = parseMetadata(rawMetadata)
if (!metadata) {
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
}
const secrets = parseSecrets(rawSecrets)
if (!secrets || secrets.journalId !== metadata.journalId) {
await removeIncompleteJournal()
return null
}
return { metadata, secrets }
const load = journalMutation.then(async () => {
const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
if (rawMetadata === null) {
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
return null
}
const metadata = parseMetadata(rawMetadata)
if (!metadata) {
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
}
const secrets = parseSecrets(rawSecrets)
if (!secrets || secrets.journalId !== metadata.journalId) {
await removeIncompleteJournal()
return null
}
return { metadata, secrets }
})
// Why: recovery may load while a new scan saves; serialize the complete
// metadata/secret snapshot and any cleanup so it cannot delete the new journal.
journalMutation = load.then(
() => undefined,
() => undefined
)
return load
}
async function removeIncompleteJournal(): Promise<void> {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.142-rc.0",
"version": "1.4.142-rc.3",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -76,7 +76,7 @@
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless",
"test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
"test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
"test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs",
+3 -3
View File
@@ -15,7 +15,7 @@ patchedDependencies:
hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
'@xterm/addon-webgl@0.20.0-beta.286':
hash: 1d4557ed6d14437403023085c037053bbc6b8566c4b08f1f712232eb3269ca78
hash: cf737f93f193124ffc7f302bbbf81c2290b29b562335f24263209274caaf8e64
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
node-pty@1.1.0:
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
@@ -214,7 +214,7 @@ importers:
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287)
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=1d4557ed6d14437403023085c037053bbc6b8566c4b08f1f712232eb3269ca78)(@xterm/xterm@6.1.0-beta.287)
version: 0.20.0-beta.286(patch_hash=cf737f93f193124ffc7f302bbbf81c2290b29b562335f24263209274caaf8e64)(@xterm/xterm@6.1.0-beta.287)
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -9594,7 +9594,7 @@ snapshots:
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=1d4557ed6d14437403023085c037053bbc6b8566c4b08f1f712232eb3269ca78)(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=cf737f93f193124ffc7f302bbbf81c2290b29b562335f24263209274caaf8e64)(@xterm/xterm@6.1.0-beta.287)':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
@@ -132,6 +132,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
const result = await adapter.spawn({ cols: 80, rows: 24 })
expect(result.id).toBeDefined()
expect(typeof result.id).toBe('string')
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
})
it('uses worktreeId as session prefix when provided', async () => {
@@ -506,6 +507,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(second.launchAgent).toBe('droid')
expect(second.snapshot).toBeDefined()
expect(second.snapshot).toContain('hello from shell')
expect(second.providerSequence).toEqual({
value: 'hello from shell\r\n'.length,
generation: 'continued'
})
})
it('includes rehydrateSequences in snapshot when terminal modes are active', async () => {
@@ -528,6 +533,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(result.id).toBe('brand-new')
expect(result.isReattach).toBeUndefined()
expect(result.snapshot).toBeUndefined()
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
})
})
+26 -1
View File
@@ -11,6 +11,7 @@ import { mintPtySessionId, parsePtySessionId } from './pty-session-id'
import { supportsPtyStartupBarrier } from './shell-ready'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session'
import {
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PROTOCOL_VERSION,
type CreateOrAttachResult,
type DaemonEvent,
@@ -46,6 +47,17 @@ function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null {
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
}
function providerSequenceForSpawn(
result: CreateOrAttachResult
): PtySpawnResult['providerSequence'] {
if (result.isNew) {
return { value: 0, generation: 'reset' }
}
return typeof result.snapshot?.outputSequence === 'number'
? { value: result.snapshot.outputSequence, generation: 'continued' }
: undefined
}
export type DaemonPtyAdapterOptions = {
socketPath: string
tokenPath: string
@@ -140,6 +152,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
private static FULL_CHECKPOINT_COOLDOWN_MS = 45_000
private lastFullCheckpointAt = new Map<string, number>()
supportsGitCredentialGuardHost(): boolean {
return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
}
constructor(opts: DaemonPtyAdapterOptions) {
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
this.socketPath = opts.socketPath
@@ -317,6 +333,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
const wasAlreadyManaged = this.activeSessionIds.has(sessionId)
this.activeSessionIds.add(sessionId)
const providerSequence = providerSequenceForSpawn(result)
// Cold restore: daemon created a new session but disk history shows
// an unclean shutdown → return saved scrollback so the renderer can
@@ -348,10 +365,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
pid,
...launchIdentity(),
coldRestore,
...(providerSequence ? { providerSequence } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
}
return { id: sessionId, pid, ...launchIdentity() }
return {
id: sessionId,
pid,
...launchIdentity(),
...(providerSequence ? { providerSequence } : {})
}
}
if (this.historyManager && result.isNew) {
@@ -389,6 +412,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
id: sessionId,
pid,
...launchIdentity(),
...(providerSequence ? { providerSequence } : {}),
...(isReattach ? { isReattach: true } : {})
}
}
@@ -410,6 +434,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
snapshot: snapshotPayload,
snapshotCols: result.snapshot.cols,
snapshotRows: result.snapshot.rows,
...(providerSequence ? { providerSequence } : {}),
...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
: {}),
+16 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { DaemonPtyRouter } from './daemon-pty-router'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import { GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types'
type AdapterMock = DaemonPtyAdapter & {
emitData: (id: string, data: string, sequenceChars?: number) => void
@@ -22,7 +23,8 @@ function buildSessionIds(prefix: string, count: number): string[] {
function createAdapter(
label: string,
sessions: string[] = [],
reconcileResult?: { alive: string[]; killed: string[] }
reconcileResult?: { alive: string[]; killed: string[] },
protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
): AdapterMock {
const writes: { id: string; data: string }[] = []
const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] =
@@ -30,6 +32,9 @@ function createAdapter(
const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
const exitListeners: ((payload: { id: string; code: number }) => void)[] = []
return {
protocolVersion,
supportsGitCredentialGuardHost: () =>
protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
spawn: vi.fn(async (opts: PtySpawnOptions): Promise<PtySpawnResult> => {
const id = opts.sessionId ?? `${label}-new`
sessions.push(id)
@@ -122,6 +127,16 @@ function createAdapter(
}
describe('DaemonPtyRouter', () => {
it('reports guard-host support for the adapter that owns the session', async () => {
const current = createAdapter('current', [], undefined, 22)
const legacy = createAdapter('legacy', ['legacy-session'], undefined, 21)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
expect(router.supportsGitCredentialGuardHost()).toBe(true)
expect(router.supportsGitCredentialGuardHost('legacy-session')).toBe(false)
})
it('routes fresh foreground confirmation to the session-owning daemon', async () => {
const current = createAdapter('current', ['current-session'])
const legacy = createAdapter('legacy', ['legacy-session'])
+5
View File
@@ -62,6 +62,11 @@ export class DaemonPtyRouter implements IPtyProvider {
return result
}
supportsGitCredentialGuardHost(sessionId?: string): boolean {
const adapter = sessionId ? this.adapterFor(sessionId) : this.current
return adapter.supportsGitCredentialGuardHost()
}
async attach(id: string): Promise<void> {
await this.adapterFor(id).attach(id)
}
+143
View File
@@ -68,6 +68,8 @@ vi.mock('../providers/agent-foreground-process', () => ({
}))
import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess'
import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_ATTRIBUTION_SHIM_DIR',
@@ -186,6 +188,147 @@ describe('createPtySubprocess', () => {
)
})
it('appends Git prompt guards after the detached daemon inherited config', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const previousWslEnv = process.env.WSLENV
const savedGitConfigEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) =>
/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)
)
)
for (const key of Object.keys(process.env)) {
if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) {
delete process.env[key]
}
}
process.env.GIT_CONFIG_COUNT = '1'
process.env.GIT_CONFIG_KEY_0 = 'core.quotePath'
process.env.GIT_CONFIG_VALUE_0 = 'false'
process.env.WSLENV = 'DAEMON_ONLY/p'
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
createPtySubprocess({
sessionId: 'guarded-git-config',
cols: 80,
rows: 24,
env: {
COMSPEC: CMD_ABS,
[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard'
}
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(spawnEnv.GCM_INTERACTIVE).toBe('never')
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('3')
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('core.quotePath')
expect(spawnEnv.GIT_CONFIG_VALUE_0).toBe('false')
expect(spawnEnv.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(spawnEnv.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
expect((spawnEnv.WSLENV ?? '').split(':')).toContain('DAEMON_ONLY/p')
expect((spawnEnv.WSLENV ?? '').split(':')).toContain('GIT_CONFIG_KEY_2')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
for (const key of Object.keys(process.env)) {
if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) {
delete process.env[key]
}
}
Object.assign(process.env, savedGitConfigEnv)
if (previousWslEnv === undefined) {
delete process.env.WSLENV
} else {
process.env.WSLENV = previousWslEnv
}
}
})
it('does not infer a guard from caller-set prompt scalars', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const savedGitConfigEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) =>
/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)
)
)
for (const key of Object.keys(process.env)) {
if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) {
delete process.env[key]
}
}
process.env.GIT_CONFIG_COUNT = '3'
process.env.GIT_CONFIG_KEY_0 = 'core.quotePath'
process.env.GIT_CONFIG_VALUE_0 = 'false'
process.env.GIT_CONFIG_KEY_1 = 'base.one'
process.env.GIT_CONFIG_VALUE_1 = 'one'
process.env.GIT_CONFIG_KEY_2 = 'base.two'
process.env.GIT_CONFIG_VALUE_2 = 'two'
try {
createPtySubprocess({
sessionId: 'explicit-guarded-git-config',
cols: 80,
rows: 24,
env: {
SHELL: '/bin/bash',
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
}
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(spawnEnv.GCM_INTERACTIVE).toBe('never')
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('1')
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(spawnEnv.GIT_CONFIG_VALUE_0).toBe('http://proxy.invalid')
expect(Object.values(spawnEnv)).not.toContain('core.quotePath')
expect(Object.values(spawnEnv)).not.toContain('base.one')
expect(Object.values(spawnEnv)).not.toContain('base.two')
expect(spawnEnv.GIT_CONFIG_KEY_1).toBeUndefined()
} finally {
for (const key of Object.keys(process.env)) {
if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) {
delete process.env[key]
}
}
Object.assign(process.env, savedGitConfigEnv)
}
})
it('guards a trusted daemon agent whose launch command is wrapped', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
createPtySubprocess({
sessionId: 'trusted-wrapped-agent',
cols: 80,
rows: 24,
command: 'cd /repo && custom-agent-wrapper',
launchAgent: 'claude',
env: { SHELL: '/bin/bash' }
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(spawnEnv.GCM_INTERACTIVE).toBe('never')
expect(Object.values(spawnEnv)).toContain('credential.interactive')
expect(Object.values(spawnEnv)).toContain('credential.guiPrompt')
})
it('uses a new daemon protocol for post-merge Git guard behavior', () => {
expect(PROTOCOL_VERSION).toBeGreaterThan(21)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(21)
})
it('resolves a missing Unix default before spawning node-pty', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
+25 -2
View File
@@ -34,6 +34,11 @@ import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { parseWslPath } from '../wsl'
import { addWslEnvKeys } from '../wsl-env'
import {
gitCredentialPromptGuardEnv,
mergeGitConfigEnvProtocol
} from '../../shared/git-credential-prompt-env'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard'
import { getWslContextFromSessionId } from './wsl-session-context'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import {
@@ -58,6 +63,7 @@ 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'
import type { TuiAgent } from '../../shared/types'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -82,6 +88,22 @@ const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000
const PTY_SPAWN_HEALTH_RETRY_ATTEMPTS = 2
const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024
function composeGuardedDaemonGitConfigEnv(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined,
launchAgent: TuiAgent | undefined
): void {
const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
if (policy !== 'guard' && launchAgent === undefined) {
return
}
// Why: the daemon can outlive Electron, so only its process.env is the
// authoritative inherited config. The raw env merge already gives an
// explicit wire protocol normal override semantics; append only the guard.
Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform))
}
export type PtySubprocessOptions = {
sessionId: string
cols: number
@@ -91,6 +113,7 @@ export type PtySubprocessOptions = {
envToDelete?: string[]
command?: string
startupCommandDelivery?: StartupCommandDelivery
launchAgent?: TuiAgent
/** Explicit shell executable path/basename the renderer asked for.
* Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user
* who picks "New WSL terminal" from the "+" menu actually gets WSL. */
@@ -543,8 +566,7 @@ function spawnDaemonPtyWithWindowsFallback(args: {
export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle {
const size = normalizePtySize(opts.cols, opts.rows)
const env: Record<string, string> = {
...process.env,
...opts.env,
...mergeGitConfigEnvProtocol(process.env, opts.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
@@ -561,6 +583,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// restores clickable refs like `owner/repo#123` / `PR#123`.
FORCE_HYPERLINK: '1'
} as Record<string, string>
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
for (const key of opts.envToDelete ?? []) {
delete env[key]
}
+6 -2
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Session, type SubprocessHandle } from './session'
import { TerminalHost } from './terminal-host'
import type { TuiAgent } from '../../shared/types'
function createMockSubprocess(
options: { startupCommandDeliveredInShellArgs?: boolean; shellPath?: string } = {}
@@ -45,6 +46,7 @@ type MockSpawnFn = (opts: {
cwd?: string
env?: Record<string, string>
command?: string
launchAgent?: TuiAgent
}) => SubprocessHandle
describe('TerminalHost', () => {
@@ -124,13 +126,14 @@ describe('TerminalHost', () => {
expect(result.snapshot?.cols).toBe(80)
})
it('passes cwd and env to spawn', async () => {
it('passes cwd, env, and trusted agent identity to spawn', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
cwd: '/home/user',
env: { FOO: 'bar' },
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
@@ -138,7 +141,8 @@ describe('TerminalHost', () => {
expect.objectContaining({
sessionId: 'session-1',
cwd: '/home/user',
env: { FOO: 'bar' }
env: { FOO: 'bar' },
launchAgent: 'claude'
})
)
})
+1
View File
@@ -100,6 +100,7 @@ export class TerminalHost {
envToDelete: opts.envToDelete,
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery,
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation
+3 -2
View File
@@ -16,9 +16,10 @@ import type { TuiAgent } from '../../shared/types'
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
// Why: bump when adding daemon wire behavior so same-version old daemons do
// not silently accept the handshake and then reject new RPCs.
export const PROTOCOL_VERSION = 21
export const PROTOCOL_VERSION = 22
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21
] as const
// ─── Session State Machine ──────────────────────────────────────────
+53
View File
@@ -501,6 +501,59 @@ describe('runner execFile timeout handling', () => {
})
})
it('forwards synthesized network SSH policy into the selected WSL distro', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
const shellCommand = args[5] as string
if (shellCommand.includes("'config'")) {
cb(Object.assign(new Error('missing'), { code: 1 }), '', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: String.raw`C:\repo`,
env: {},
wslDistro: 'Ubuntu',
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
expect((capturedEnv?.WSLENV ?? '').split(':')).toContain('GIT_SSH_COMMAND')
})
})
it('forwards synthesized network SSH policy when a UNC cwd selects WSL', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
const shellCommand = args[5] as string
if (shellCommand.includes("'config'")) {
cb(Object.assign(new Error('missing'), { code: 1 }), '', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: String.raw`\\wsl.localhost\Ubuntu\home\me\repo`,
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
expect((capturedEnv?.WSLENV ?? '').split(':')).toContain('GIT_SSH_COMMAND')
})
})
it('quotes WSL-routed executables before entering the shell', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)
+97
View File
@@ -9,9 +9,11 @@ import {
nonInteractiveGitEnv,
parseRetryAfterMs,
promptGuardGitEnv,
promptGuardShellEnv,
redirectPortedHostnameToEnv,
untranslatedGitOutputEnv
} from './runner'
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
// Reads git config injected via the GIT_CONFIG_COUNT/KEY/VALUE env protocol
// back into a plain key→value map so tests can assert on it directly.
@@ -189,6 +191,47 @@ describe('appendGitConfigEnv', () => {
expect(env.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt')
expect(env.GIT_CONFIG_VALUE_1).toBe('false')
})
it.each(['bogus', '-1', '0', String(Number.MAX_SAFE_INTEGER)])(
'does not overwrite dangling caller config when count is %s',
(count) => {
const original = {
GIT_CONFIG_COUNT: count,
GIT_CONFIG_KEY_0: 'user.key',
GIT_CONFIG_VALUE_0: 'caller-value'
}
expect(appendGitConfigEnv(original, [['credential.interactive', 'false']])).toEqual(original)
}
)
it('does not append to an incomplete indexed-config protocol', () => {
const original = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'user.key' }
expect(appendGitConfigEnv(original, [['credential.interactive', 'false']])).toEqual(original)
})
})
describe('mergeGitConfigEnvProtocol', () => {
it('replaces inherited indexed config atomically when an override has a smaller count', () => {
const env = mergeGitConfigEnvProtocol(
{
GIT_CONFIG_COUNT: '2',
GIT_CONFIG_KEY_0: 'base.zero',
GIT_CONFIG_VALUE_0: 'zero',
GIT_CONFIG_KEY_1: 'base.one',
GIT_CONFIG_VALUE_1: 'one'
},
{
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'override.zero',
GIT_CONFIG_VALUE_0: 'override'
}
)
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_KEY_0).toBe('override.zero')
expect(env.GIT_CONFIG_KEY_1).toBeUndefined()
expect(env.GIT_CONFIG_VALUE_1).toBeUndefined()
})
})
describe('promptGuardGitEnv credential-interactivity disable (STA-1292)', () => {
@@ -220,6 +263,60 @@ describe('nonInteractiveGitEnv credential-interactivity disable (STA-1292)', ()
})
})
describe('guard-env WSLENV forwarding (#7652)', () => {
it('registers the guard vars in WSLENV on Windows so WSL-routed git imports them', () => {
const env = promptGuardGitEnv({ PATH: '/usr/bin' }, 'win32')
const keys = (env.WSLENV ?? '').split(':')
expect(keys).toContain('GIT_TERMINAL_PROMPT')
expect(keys).toContain('GCM_INTERACTIVE')
expect(keys).toContain('GIT_CONFIG_COUNT')
expect(keys).toContain('GIT_CONFIG_KEY_0')
expect(keys).toContain('GIT_CONFIG_VALUE_0')
expect(keys).toContain('GIT_CONFIG_KEY_1')
expect(keys).toContain('GIT_CONFIG_VALUE_1')
// Windows askpass paths are meaningless inside a distro.
expect(keys).not.toContain('GIT_ASKPASS')
expect(keys).not.toContain('SSH_ASKPASS')
})
it('preserves a caller-set WSLENV instead of clobbering it', () => {
const env = promptGuardGitEnv({ PATH: '/usr/bin', WSLENV: 'MY_VAR/p' }, 'win32')
const keys = (env.WSLENV ?? '').split(':')
expect(keys[0]).toBe('MY_VAR/p')
expect(keys).toContain('GIT_TERMINAL_PROMPT')
})
it('does not touch WSLENV on non-Windows hosts', () => {
const env = promptGuardGitEnv({ PATH: '/usr/bin' }, 'darwin')
expect(env.WSLENV).toBeUndefined()
})
it('forwards GIT_SSH_COMMAND only when nonInteractiveGitEnv set the default itself', () => {
const defaulted = nonInteractiveGitEnv({ PATH: '/usr/bin' }, 'win32')
expect((defaulted.WSLENV ?? '').split(':')).toContain('GIT_SSH_COMMAND')
// A caller's Windows-specific ssh command must not leak into the distro.
const callerSet = nonInteractiveGitEnv(
{ PATH: '/usr/bin', GIT_SSH_COMMAND: 'C:\\ssh\\ssh.exe' },
'win32'
)
expect((callerSet.WSLENV ?? '').split(':')).not.toContain('GIT_SSH_COMMAND')
})
})
describe('promptGuardShellEnv keeps the shell locale (#7652 x #7808)', () => {
it('guards without pinning the locale — a terminal env is the whole shell, not just git', () => {
const env = promptGuardShellEnv({ PATH: '/usr/bin', LC_ALL: 'ja_JP.UTF-8' }, 'win32')
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect((env.WSLENV ?? '').split(':')).toContain('GIT_TERMINAL_PROMPT')
// The user's locale survives; no pins appear where none existed.
expect(env.LC_ALL).toBe('ja_JP.UTF-8')
expect(env.LANG).toBeUndefined()
expect(env.LANGUAGE).toBeUndefined()
})
})
describe('git env forces untranslated diagnostics (issue #7808)', () => {
it('overrides an inherited non-English locale so stderr parsers keep working', () => {
// A gettext-enabled git under de_DE translates even the `fatal:` prefix,
+50 -42
View File
@@ -29,6 +29,11 @@ import {
notifyGhPrimaryRateLimit
} from './gh-rate-limit-breaker'
import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
import { addWslEnvKeys } from '../wsl-env'
import {
appendGitConfigEnv,
gitCredentialPromptGuardEnv
} from '../../shared/git-credential-prompt-env'
import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils'
import {
buildWslLoginShellCommand,
@@ -558,20 +563,7 @@ export function gitOptionalLocksDisabledEnv(
* already present in `env` so we never clobber config a caller injected the
* same way.
*/
export function appendGitConfigEnv(
env: NodeJS.ProcessEnv,
entries: readonly (readonly [key: string, value: string])[]
): NodeJS.ProcessEnv {
const parsed = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10)
const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0
const next = { ...env }
entries.forEach(([key, value], index) => {
next[`GIT_CONFIG_KEY_${base + index}`] = key
next[`GIT_CONFIG_VALUE_${base + index}`] = value
})
next.GIT_CONFIG_COUNT = String(base + entries.length)
return next
}
export { appendGitConfigEnv }
/**
* Pin Orca-spawned git to untranslated English output so stderr/progress
@@ -584,26 +576,25 @@ export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env):
return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV }
}
export function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return appendGitConfigEnv(
{
...untranslatedGitOutputEnv(env),
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
SSH_ASKPASS: env.SSH_ASKPASS ?? '',
// Why: Git Credential Manager ignores GIT_TERMINAL_PROMPT / GIT_ASKPASS and
// pops a GUI on first auth — the Windows worktree-create hang (STA-1292).
// `never` suppresses the prompt while still serving cached credentials.
GCM_INTERACTIVE: 'never'
},
// Why: disable only the *interactive* credential prompt, NOT the helper
// itself — an empty credential.helper would break cached-credential auth for
// private repos. Harmless on macOS/Linux (no GCM) and on the SSH path.
[
['credential.interactive', 'false'],
['credential.guiPrompt', 'false']
]
)
export function promptGuardGitEnv(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
return gitCredentialPromptGuardEnv(untranslatedGitOutputEnv(env), platform)
}
/**
* Credential-prompt guard for a general-purpose shell environment (terminal
* PTYs, hook scripts): everything promptGuardGitEnv does EXCEPT the issue-7808
* locale pins. Those exist so Orca can parse stderr of git it spawns itself;
* forcing LC_ALL/LANG/LANGUAGE onto a user's shell would change the locale of
* every child process, not just git's.
*/
export function promptGuardShellEnv(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
return gitCredentialPromptGuardEnv(env, platform)
}
/**
@@ -614,17 +605,28 @@ export function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.
* stuck calls pile up and the runtime stops answering all clients (issue #5308).
*
* - GIT_TERMINAL_PROMPT=0: git refuses to prompt for credentials and errors out.
* - GIT_ASKPASS / SSH_ASKPASS='': disable any GUI/askpass credential helper that
* would otherwise pop a prompt and block.
* - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI/askpass helper can
* pop a prompt and block. A caller-provided askpass is preserved on purpose —
* custom askpass setups commonly *serve* credentials non-interactively, and
* blanking them would break those fetches.
* - GIT_SSH_COMMAND BatchMode=yes: SSH fails instead of waiting on an
* interactive password/host-key prompt. BatchMode does NOT change host trust
* (an unknown host still errors, it just won't hang). Only added when the
* caller hasn't set its own GIT_SSH_COMMAND.
*/
export function nonInteractiveGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const next = promptGuardGitEnv(env)
export function nonInteractiveGitEnv(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
const next = promptGuardGitEnv(env, platform)
if (!next.GIT_SSH_COMMAND) {
next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
if (platform === 'win32') {
// Why: forward across the WSL boundary only when we set the value —
// plain `ssh` resolves inside the distro, whereas a caller's
// Windows-specific GIT_SSH_COMMAND must not leak into Linux git.
addWslEnvKeys(next, ['GIT_SSH_COMMAND'])
}
}
return next
}
@@ -775,7 +777,12 @@ async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{
}
if (!configuredCommand) {
return { env: { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }, mode: 'fallback' }
const env = { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
// Why: WSL routing can come from either an explicit distro or a UNC cwd.
if (resolved.wsl) {
addWslEnvKeys(env, ['GIT_SSH_COMMAND'])
}
return { env, mode: 'fallback' }
}
const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand)
@@ -785,10 +792,11 @@ async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{
return { env: promptEnv, mode: 'configured-wrapper-passthrough' }
}
return {
env: { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand },
mode: 'configured-openssh'
const env = { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand }
if (resolved.wsl) {
addWslEnvKeys(env, ['GIT_SSH_COMMAND'])
}
return { env, mode: 'configured-openssh' }
}
/**
+33 -3
View File
@@ -1,5 +1,6 @@
/* eslint-disable max-lines -- Why: hook parsing, shell selection, and execution-path regressions are tightly coupled, so these cases stay in one file to preserve the behavior matrix across platforms. */
import type { Repo } from '../shared/types'
import type * as GitRunner from './git/runner'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -29,7 +30,8 @@ vi.mock('child_process', () => ({
spawn: vi.fn()
}))
vi.mock('./git/runner', () => ({
vi.mock('./git/runner', async () => ({
...(await vi.importActual<typeof GitRunner>('./git/runner')),
gitExecFileSync: gitExecFileSyncMock
}))
@@ -892,7 +894,13 @@ describe('runHook', () => {
'echo hello',
expect.objectContaining({
cwd: '/repo/worktree',
shell: '/bin/bash'
shell: '/bin/bash',
// Setup hooks run unattended: git in them must not pop the OS
// credential helper's OAuth window and loop it (issue #7652).
env: expect.objectContaining({
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never'
})
}),
expect.any(Function)
)
@@ -948,7 +956,15 @@ describe('runHook', () => {
expect(execFileMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/feature' && echo hello"],
expect.any(Object),
// #7652 regression: the unattended WSL hook branch must carry the
// credential guard, and WSLENV is what carries it into the distro.
expect.objectContaining({
env: expect.objectContaining({
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never',
WSLENV: expect.stringContaining('GIT_TERMINAL_PROMPT')
})
}),
expect.any(Function)
)
expect(execMock).not.toHaveBeenCalled()
@@ -1153,6 +1169,20 @@ describe('createSetupRunnerScript', () => {
.waitForAgentStartup
).toBe(true)
})
it('marks setup-runner terminals for the always-on credential guard', async () => {
gitExecFileSyncMock.mockReset()
gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n')
const { createSetupRunnerScript } = await import('./hooks')
const setup = createSetupRunnerScript(makeRepo(), '/test/worktree', 'git fetch')
expect(setup.envVars).toMatchObject({
ORCA_ROOT_PATH: '/test/repo',
ORCA_WORKTREE_PATH: '/test/worktree',
ORCA_INTERNAL_TERMINAL_GIT_CREDENTIAL_GUARD_POLICY: 'guard'
})
})
})
describe('shouldRunSetupForCreate', () => {
+21 -6
View File
@@ -6,8 +6,9 @@ import { getDefaultRepoHookSettings } from '../shared/constants'
import { getRuntimePathBasename } from '../shared/cross-platform-path'
import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard'
import { parseOrcaYaml } from '../shared/orca-yaml'
import { gitExecFileSync } from './git/runner'
import { gitExecFileSync, promptGuardShellEnv } from './git/runner'
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
import type {
HookCommandSourcePolicy,
@@ -455,7 +456,12 @@ export function createSetupRunnerScript(
}
export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
return getSetupEnvVars(repo, worktreePath)
return {
...getSetupEnvVars(repo, worktreePath),
// Why: the visible Setup terminal is still unattended automation; user
// terminal opt-out must not let its git commands open credential UI.
[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard'
}
}
export function buildPosixRunnerScript(script: string): string {
@@ -511,7 +517,7 @@ function createWorktreeRunnerScript(
runtimeTarget?: HookRuntimeTarget,
waitForAgentStartup?: boolean
): WorktreeSetupLaunch {
const envVars = getSetupEnvVars(repo, worktreePath)
const envVars = getSetupRunnerEnvVars(repo, worktreePath)
// Why: WSL worktrees run on a Linux filesystem even though process.platform
// is 'win32'. Use bash scripts for WSL, .cmd for native Windows.
const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro)
@@ -636,7 +642,11 @@ export function runHook(
{
timeout: HOOK_TIMEOUT,
encoding: 'utf-8',
env: { ...process.env, ...wslEnv }
// Why: same unattended-git guard as the non-WSL branch below
// (issue #7652) — WSL repos are the likeliest to hit the GCM
// popup, and the guard's WSLENV registration is what carries it
// across the wsl.exe boundary into the distro.
env: promptGuardShellEnv({ ...process.env, ...wslEnv })
},
(error, stdout, stderr) => {
finish(error ?? null, stdout, stderr)
@@ -655,10 +665,15 @@ export function runHook(
cwd,
timeout: HOOK_TIMEOUT,
shell: getHookShell(),
env: {
// Why: setup/archive hooks run unattended, so a `git fetch`/`submodule
// update` inside one must never make Git Credential Manager pop its
// "Connect to GitHub" OAuth window on Windows and loop when the network
// can't complete it (issue #7652). The guard keeps the credential
// helper, so cached auth still works; only the interactive prompt dies.
env: promptGuardShellEnv({
...process.env,
...getSetupEnvVars(repo, cwd)
}
})
},
(error, stdout, stderr) => {
if (error) {
+16 -8
View File
@@ -899,7 +899,7 @@ describe('registerNotificationHandlers', () => {
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications for focused active-worktree notifications', async () => {
it('dispatches one mobile notification when the active worktree is focused on desktop', async () => {
getAllWindowsMock.mockReturnValue([
{
isDestroyed: () => false,
@@ -922,17 +922,25 @@ describe('registerNotificationHandlers', () => {
)
const handler = getDispatchHandler()
expect(
await handler(
{},
{ source: 'agent-task-complete', worktreeId: 'repo::wt1', isActiveWorktree: true }
)
).toEqual({
const focusedNotification = {
source: 'agent-task-complete' as const,
worktreeId: 'repo::wt1',
isActiveWorktree: true
}
expect(await handler({}, focusedNotification)).toEqual({
delivered: false,
reason: 'suppressed-focus'
})
expect(await handler({}, focusedNotification)).toEqual({
delivered: false,
reason: 'suppressed-focus'
})
expect(dispatchMobileNotification).not.toHaveBeenCalled()
expect(dispatchMobileNotification).toHaveBeenCalledTimes(1)
expect(dispatchMobileNotification).toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' })
)
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications for cooldown-suppressed bursts', async () => {
+36 -26
View File
@@ -312,8 +312,24 @@ function pruneRecentNotifications(recentNotifications: Map<string, number>, now:
}
}
function reserveNotificationCooldown(
recentNotifications: Map<string, number>,
dedupeKey: string,
now: number
): boolean {
const lastSentAt = recentNotifications.get(dedupeKey) ?? 0
if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) {
return false
}
recentNotifications.delete(dedupeKey)
recentNotifications.set(dedupeKey, now)
pruneRecentNotifications(recentNotifications, now)
return true
}
export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void {
const recentNotifications = new Map<string, number>()
const recentDesktopNotifications = new Map<string, number>()
const recentMobileNotifications = new Map<string, number>()
// Why: handler registration marks a fresh session — permission evidence
// from a previous registration must not leak into the new one.
lastObservedDeliveryOutcome = null
@@ -441,6 +457,24 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
return { delivered: false, reason: 'source-disabled' }
}
const notificationOptions = buildNotificationOptions(args)
// Why: desktop focus only means this computer has the worktree visible;
// the paired phone may be locked or elsewhere and still needs the alert.
if (runtime && args.source !== 'test') {
const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global'
if (reserveNotificationCooldown(recentMobileNotifications, dedupeKey, Date.now())) {
runtime.dispatchMobileNotification({
type: 'notification',
source: args.source,
title: notificationOptions.title,
body: notificationOptions.body,
worktreeId: args.worktreeId,
...(args.notificationId ? { notificationId: args.notificationId } : {})
})
}
}
const browserWindow =
BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()) ?? null
if (
@@ -458,33 +492,9 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
// Dedupe by worktree, not by source — an agent finishing and a terminal bell
// often fire within the same data chunk so only the first one should surface.
const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global'
const now = Date.now()
const lastSentAt = recentNotifications.get(dedupeKey) ?? 0
if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) {
if (!reserveNotificationCooldown(recentDesktopNotifications, dedupeKey, Date.now())) {
return { delivered: false, reason: 'cooldown' }
}
recentNotifications.delete(dedupeKey)
recentNotifications.set(dedupeKey, now)
// Why: a storm across many worktrees should not make every
// notification dispatch scan an ever-growing cooldown table.
pruneRecentNotifications(recentNotifications, now)
}
const notificationOptions = buildNotificationOptions(args)
// Why: paired mobile clients should follow the same user-facing
// notification gates as desktop delivery, while still working on hosts
// where Electron native notifications are unavailable.
if (runtime && args.source !== 'test') {
runtime.dispatchMobileNotification({
type: 'notification',
source: args.source,
title: notificationOptions.title,
body: notificationOptions.body,
worktreeId: args.worktreeId,
...(args.notificationId ? { notificationId: args.notificationId } : {})
})
}
if (!Notification.isSupported()) {
+151 -8
View File
@@ -11,6 +11,7 @@ import {
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import type { TuiAgent } from '../../shared/types'
const isWindowsHost = process.platform === 'win32'
const posixOnlyIt = isWindowsHost ? it.skip : it
@@ -759,7 +760,8 @@ describe('registerPtyHandlers', () => {
// buildPtyHostEnv to piTitlebarExtensionService.buildPtyEnv was untested
// for the OMP case because this helper never forwarded a command. Accept
// an optional `command` so callers can exercise OMP target resolution.
command?: string
command?: string,
launchAgent?: TuiAgent
): Promise<Record<string, string>> {
const savedEnv: Record<string, string | undefined> = {}
if (processEnvOverrides) {
@@ -787,7 +789,8 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
...(argsEnv ? { env: argsEnv } : {}),
...(command ? { command } : {})
...(command ? { command } : {}),
...(launchAgent ? { launchAgent } : {})
})
const spawnCall = spawnMock.mock.calls.at(-1)!
return spawnCall[2].env as Record<string, string>
@@ -876,6 +879,27 @@ describe('registerPtyHandlers', () => {
expect(env.TERM_PROGRAM).toBe('Orca')
})
it('keeps indexed Git prompt guards in a local agent terminal env', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, 'claude')
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(Object.values(env)).toContain('credential.interactive')
expect(Object.values(env)).toContain('credential.guiPrompt')
})
it('guards a trusted local agent when its command uses a custom wrapper', async () => {
const env = await spawnAndGetEnv(
undefined,
undefined,
undefined,
undefined,
'cd /repo && custom-agent-wrapper',
'claude'
)
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
})
it('advertises OSC 8 hyperlink support via FORCE_HYPERLINK', async () => {
// Why: the supports-hyperlinks npm package hard-codes a TERM_PROGRAM
// allowlist (iTerm.app / WezTerm / vscode) and reports false for
@@ -1361,7 +1385,7 @@ describe('registerPtyHandlers', () => {
// OpenCode plugin dir, Pi managed extension env, Codex home, and dev-mode CLI
// overrides were silently missing for daemon users (the common case).
function setupDaemonAdapter() {
function setupDaemonAdapter(supportsGitCredentialGuardHost = true) {
const daemonSpawn = vi.fn(
async (options: {
env: Record<string, string>
@@ -1373,6 +1397,7 @@ describe('registerPtyHandlers', () => {
)
setLocalPtyProvider({
spawn: daemonSpawn,
supportsGitCredentialGuardHost: () => supportsGitCredentialGuardHost,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
@@ -1449,9 +1474,10 @@ describe('registerPtyHandlers', () => {
shellOverride?: string
command?: string
envToDelete?: string[]
}
},
supportsGitCredentialGuardHost = true
): Promise<DaemonSpawnCall> {
const daemonSpawn = setupDaemonAdapter()
const daemonSpawn = setupDaemonAdapter(supportsGitCredentialGuardHost)
const savedEnv: Record<string, string | undefined> = {}
if (processEnvOverrides) {
for (const [k, v] of Object.entries(processEnvOverrides)) {
@@ -1498,7 +1524,8 @@ describe('registerPtyHandlers', () => {
httpProxyBypassRules?: string
},
processEnvOverrides?: Record<string, string | undefined>,
spawnArgs?: { cwd?: string; shellOverride?: string; command?: string }
spawnArgs?: { cwd?: string; shellOverride?: string; command?: string },
supportsGitCredentialGuardHost = true
): Promise<Record<string, string>> {
return (
await daemonSpawnAndGetOptions(
@@ -1506,7 +1533,8 @@ describe('registerPtyHandlers', () => {
getSelectedCodexHomePath,
getSettings,
processEnvOverrides,
spawnArgs
spawnArgs,
supportsGitCredentialGuardHost
)
).env
}
@@ -2065,6 +2093,47 @@ describe('registerPtyHandlers', () => {
}
})
it('defers indexed Git prompt guards from the daemon wire environment', async () => {
const env = await daemonSpawnAndGetEnv(
{
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
},
undefined,
undefined,
undefined,
{ command: 'claude' }
)
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(env.GIT_CONFIG_KEY_1).toBeUndefined()
})
it('materializes the full guard for a legacy daemon host', async () => {
const env = await daemonSpawnAndGetEnv(
{
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
},
undefined,
undefined,
undefined,
{ command: 'claude' },
false
)
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(env.GIT_CONFIG_COUNT).toBe('3')
expect(env.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(env.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
})
it('passes the minted sessionId through to provider.spawn and host env setup', async () => {
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
@@ -10437,6 +10506,44 @@ describe('registerPtyHandlers', () => {
expect(mockProc.proc.write).not.toHaveBeenCalled()
})
it('synchronizes runtime output sequencing from a provider reattach snapshot', async () => {
setLocalPtyProvider({
spawn: vi.fn(async () => ({
id: 'pty-restored',
isReattach: true,
providerSequence: { value: 900, generation: 'continued' as const }
})),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getPtyOutputSequence: vi.fn().mockReturnValue(7),
synchronizePtyOutputSequenceFromProvider: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
preAllocateHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(runtime.synchronizePtyOutputSequenceFromProvider).toHaveBeenCalledWith(
'pty-restored',
{ value: 900, generation: 'continued' },
7
)
})
it('seeds headless terminal state with cold-restore cwd metadata', async () => {
const oscLinks = [{ row: 0, startCol: 0, endCol: 8, uri: 'https://example.com/restored' }]
const coldRestore = {
@@ -10474,7 +10581,7 @@ describe('registerPtyHandlers', () => {
'pty-cold-restore',
'restored history\r\n',
undefined,
{ cwd: '/projects/restored', oscLinks }
{ cwd: '/projects/restored', oscLinks, preferProviderIfExisting: true }
)
})
@@ -11291,6 +11398,42 @@ describe('registerPtyHandlers', () => {
})
})
describe('provider buffer snapshot dispatch', () => {
it('exposes daemon history when no renderer pane is mounted', async () => {
const provider = installObservableDaemonTestProvider()
provider.getBufferSnapshot.mockResolvedValue({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
serializeProviderBuffer(ptyId: string, opts?: { scrollbackRows?: number }): Promise<unknown>
}
await expect(
controller.serializeProviderBuffer('daemon-restored', { scrollbackRows: 5000 })
).resolves.toEqual({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-restored', {
scrollbackRows: 5000
})
})
})
describe('main buffer snapshot dispatch', () => {
it('returns a hidden-output recovery snapshot with clamped scrollback', async () => {
const runtime = {
+59 -3
View File
@@ -41,6 +41,7 @@ import {
isWslShellName,
resolveLocalWindowsTerminalRuntimeOptions
} from '../../shared/local-windows-terminal-runtime'
import { applyTerminalGitCredentialPromptGuard } from './terminal-git-credential-guard'
import { openCodeHookService } from '../opencode/hook-service'
import { mimoCodeHookService } from '../mimo/hook-service'
import {
@@ -553,6 +554,8 @@ export type BuildPtyHostEnvOptions = {
* resolve to Pi for back-compat. NEVER infer from disk presence; that's
* the bug this option fixes (cross-agent shadowing when both dirs exist). */
launchCommand?: string
/** Trusted agent identity for wrapped commands that cannot be recognized from text. */
launchAgent?: TuiAgent
shellPath?: string
isWsl?: boolean
/** Distro for WSL spawns (null = Windows default distro). Drives the WSL
@@ -560,6 +563,9 @@ export type BuildPtyHostEnvOptions = {
wslDistro?: string | null
agentStatusHooksEnabled: boolean
networkProxySettings?: NetworkProxySettings
/** Keep indexed Git config off the sparse daemon wire; the daemon appends
* guard entries after merging its authoritative inherited environment. */
deferGitConfigGuardToDaemon?: boolean
}
function readInheritedPath(baseEnv: Record<string, string>): string {
@@ -849,6 +855,15 @@ export function buildPtyHostEnv(
const piAgentKind = detectPiAgentKindFromCommand(launchCommandHint)
const hasLaunchCommand =
typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0
// Why: unattended agents must fail instead of opening OS credential UI and
// retrying auth in a loop; ordinary user terminals keep normal Git behavior.
applyTerminalGitCredentialPromptGuard(baseEnv, {
launchCommand: launchCommandHint,
isUnattended: opts.launchAgent !== undefined,
deferGitConfigGuardToHost: opts.deferGitConfigGuardToDaemon
})
const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand
// Why: source shadows are agent-scoped. Trusting the other kind's source
// would reintroduce the exact Pi/OMP extension-state shadowing this PR fixes.
@@ -1548,6 +1563,7 @@ export function registerPtyHandlers(
skipCodexHomeEnv: ctx?.isWsl === true && !selectedCodexHomePath,
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand: ctx?.command,
launchAgent: ctx?.launchAgent,
shellPath: ctx?.shellPath,
isWsl: ctx?.isWsl,
wslDistro: ctx?.wslDistro ?? null,
@@ -3031,11 +3047,13 @@ export function registerPtyHandlers(
skipCodexHomeEnv,
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand: args.command,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
shellPath: daemonShellOverride ?? process.env.COMSPEC,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
wslDistro: codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
networkProxySettings: getSettings?.(),
deferGitConfigGuardToDaemon: provider.supportsGitCredentialGuardHost?.(sessionId) === true
})
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
}
@@ -3071,6 +3089,9 @@ export function registerPtyHandlers(
if (args.startupCommandDelivery !== undefined) {
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
}
if (isTuiAgent(args.launchAgent)) {
spawnOptions.launchAgent = args.launchAgent
}
if (args.worktreeId !== undefined) {
spawnOptions.worktreeId = args.worktreeId
}
@@ -3125,7 +3146,18 @@ export function registerPtyHandlers(
if (args.preAllocatedHandle) {
trustedTerminalHandleEnv.add(args.preAllocatedHandle)
}
const expectedPtyId = effectiveSessionAppId ?? sessionId
const sequenceBeforeProviderSpawn = expectedPtyId
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
: 0
result = await provider.spawn(spawnOptions)
if (result.providerSequence) {
runtime?.synchronizePtyOutputSequenceFromProvider?.(
result.id,
result.providerSequence,
sequenceBeforeProviderSpawn
)
}
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
@@ -3466,6 +3498,15 @@ export function registerPtyHandlers(
// state and dimensions before live TUI chunks can render correctly.
return requestSerializedBuffer(ptyId, opts)
},
serializeProviderBuffer: async (ptyId, opts) => {
try {
// Why: restored daemon PTYs can be live while their desktop pane stays
// unmounted; query the provider model so phone-local navigation works.
return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null
} catch {
return null
}
},
hasRendererSerializer: (ptyId) => {
// Why: the runtime needs a synchronous probe so it can decide whether to
// skip the daemon-snapshot seed (the renderer will hydrate it) or run the
@@ -3888,12 +3929,15 @@ export function registerPtyHandlers(
skipCodexHomeEnv,
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand: args.command,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
wslDistro:
codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
networkProxySettings: getSettings?.(),
deferGitConfigGuardToDaemon:
provider.supportsGitCredentialGuardHost?.(effectiveSessionId) === true
})
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
} catch (err) {
@@ -4034,7 +4078,18 @@ export function registerPtyHandlers(
)
}
spawnTiming.mark('options')
const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId
const sequenceBeforeProviderSpawn = expectedPtyId
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
: 0
result = await provider.spawn(spawnOptions)
if (result.providerSequence) {
runtime?.synchronizePtyOutputSequenceFromProvider?.(
result.id,
result.providerSequence,
sequenceBeforeProviderSpawn
)
}
spawnTiming.mark('provider_spawn')
} catch (err) {
// Why: a failed spawn must not leave a stale hidden mark on a session
@@ -4269,7 +4324,8 @@ export function registerPtyHandlers(
) {
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, {
cwd: result.coldRestore.cwd,
oscLinks: result.coldRestore.oscLinks
oscLinks: result.coldRestore.oscLinks,
preferProviderIfExisting: true
})
}
}
+29 -6
View File
@@ -9,6 +9,7 @@ import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as GitRunner from '../git/runner'
import type * as RepoModule from '../git/repo'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { getGitRepoRoot, isGitRepo } from '../git/repo'
@@ -104,15 +105,15 @@ vi.mock('../git/repo', async () => {
}
})
vi.mock('../git/runner', () => ({
vi.mock('../git/runner', async () => ({
// Why: keep the real env builders (nonInteractiveGitEnv,
// gitOptionalLocksDisabledEnv) so the clone regression test (#7652) asserts
// the actual guard's markers, not a mock echoing itself.
...(await vi.importActual<typeof GitRunner>('../git/runner')),
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileAsyncBuffer: vi.fn(),
gitStreamStdout: vi.fn(),
gitSpawn: gitSpawnMock,
gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({
...env,
GIT_OPTIONAL_LOCKS: '0'
})
gitSpawn: gitSpawnMock
}))
vi.mock('../git/worktree', () => ({
@@ -2118,6 +2119,28 @@ describe('repos:add + repos:clone', () => {
expect(result).toHaveProperty('path', join(destination, 'orca'))
})
it('clones with the non-interactive credential guard so Git Credential Manager cannot pop its OAuth window (#7652)', async () => {
const destination = await createTempRoot()
await handlers.get('repos:clone')!(null, {
url: 'https://example.com/orca.git',
destination
})
// Without this env, a clone that needs GitHub auth makes Git Credential
// Manager pop its "Connect to GitHub" OAuth window on Windows and loop it
// when the network cannot complete the flow.
expect(gitSpawnMock).toHaveBeenCalledWith(
['clone', '--progress', '--', 'https://example.com/orca.git', join(destination, 'orca')],
expect.objectContaining({
env: expect.objectContaining({
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never'
})
})
)
})
it('treats cloneAbort with no active clone as a no-op', async () => {
await expect(handlers.get('repos:cloneAbort')!(null, undefined)).resolves.toBeUndefined()
})
+46 -8
View File
@@ -42,7 +42,7 @@ import { isTuiAgent } from '../../shared/tui-agent-config'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import type { ChildProcess } from 'node:child_process'
import { access, mkdir, readdir, rm } from 'node:fs/promises'
import { gitExecFileAsync, gitSpawn } from '../git/runner'
import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner'
import { isAbsolute, join, posix } from 'node:path'
import {
cleanupClaimedCloneTarget,
@@ -87,7 +87,12 @@ import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import {
getRepoExecutionHostId,
normalizeExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import {
assertFolderWorkspacePathUsable,
@@ -2255,6 +2260,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
try {
proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], {
cwd: args.destination,
// Why: without the non-interactive guard, a clone that needs
// GitHub auth makes Git Credential Manager pop its "Connect to
// GitHub" OAuth window on Windows; in a network-restricted env the
// browser/device flow can never complete and git's credential
// retry re-pops it (issue #7652). Fail fast with a clear error and
// let Orca's non-intrusive GitHub state stand instead.
env: nonInteractiveGitEnv(),
stdio: ['ignore', 'ignore', 'pipe']
})
} catch (err) {
@@ -2430,8 +2442,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.handle(
'repos:getBaseRefDefault',
async (_event, args: { repoId: string }): Promise<BaseRefDefaultResult> => {
const repo = store.getRepo(args.repoId)
async (
_event,
args: { repoId: string; hostId?: ExecutionHostId }
): Promise<BaseRefDefaultResult> => {
const repo = getRepoForExecutionHost(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
// Why: folder-mode repos have no git state to resolve a base ref from.
// Return null + 0 so the renderer can decline to use a fabricated default
@@ -2507,14 +2522,20 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.handle(
'repos:searchBaseRefs',
async (_event, args: { repoId: string; query: string; limit?: number }) => {
async (
_event,
args: { repoId: string; query: string; limit?: number; hostId?: ExecutionHostId }
) => {
return (await searchBaseRefDetailsForRepo(store, args)).map((entry) => entry.refName)
}
)
ipcMain.handle(
'repos:searchBaseRefDetails',
async (_event, args: { repoId: string; query: string; limit?: number }) => {
async (
_event,
args: { repoId: string; query: string; limit?: number; hostId?: ExecutionHostId }
) => {
return searchBaseRefDetailsForRepo(store, args)
}
)
@@ -2522,9 +2543,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
async function searchBaseRefDetailsForRepo(
store: Store,
args: { repoId: string; query: string; limit?: number }
args: { repoId: string; query: string; limit?: number; hostId?: ExecutionHostId }
): Promise<BaseRefSearchResult[]> {
const repo = store.getRepo(args.repoId)
const repo = getRepoForExecutionHost(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
return []
}
@@ -2602,6 +2623,23 @@ async function searchBaseRefDetailsForRepo(
return searchBaseRefDetails(repo.path, args.query, limit)
}
function getRepoForExecutionHost(
store: Store,
repoId: string,
hostId?: ExecutionHostId
): Repo | null {
if (!hostId) {
return store.getRepo(repoId) ?? null
}
// Why: repo ids can collide across local and SSH hosts; base-ref reads must
// use the same host selected by the Settings pane as the subsequent write.
return (
store
.getRepos()
.find((repo) => repo.id === repoId && getRepoExecutionHostId(repo) === hostId) ?? null
)
}
function notifyReposChanged(mainWindow: BrowserWindow): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('repos:changed')
@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest'
import {
applyTerminalGitCredentialPromptGuard,
TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV
} from './terminal-git-credential-guard'
function expectGuarded(env: Record<string, string>): void {
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(Object.values(env)).toContain('credential.interactive')
expect(Object.values(env)).toContain('credential.guiPrompt')
expect(Object.values(env)).not.toContain('credential.helper')
}
describe('applyTerminalGitCredentialPromptGuard', () => {
it('guards an agent terminal on every platform', () => {
for (const platform of ['win32', 'darwin', 'linux'] as const) {
const env: Record<string, string> = { PATH: '/usr/bin' }
expect(
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
platform
})
).toBe(true)
expectGuarded(env)
}
})
it('guards a headless one-shot agent launch', () => {
const env: Record<string, string> = { PATH: '/usr/bin' }
expect(
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude -p "fix the tests"',
platform: 'darwin'
})
).toBe(true)
expectGuarded(env)
})
it('guards a trusted agent whose wrapped command is not recognizable', () => {
const env: Record<string, string> = { PATH: '/usr/bin' }
expect(
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'cd /repo && custom-agent-wrapper',
isUnattended: true,
platform: 'linux'
})
).toBe(true)
expectGuarded(env)
})
it('leaves ordinary user terminals unchanged on every platform', () => {
for (const platform of ['win32', 'darwin', 'linux'] as const) {
const original = {
PATH: '/usr/bin',
GIT_TERMINAL_PROMPT: '1',
GCM_INTERACTIVE: 'auto',
GIT_ASKPASS: '/usr/local/bin/user-askpass'
}
const env = { ...original }
expect(
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
platform
})
).toBe(false)
expect(env).toEqual(original)
}
})
it('does not treat a generic Orca CLI command as an agent', () => {
const env: Record<string, string> = { PATH: '/usr/bin' }
expect(
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'orca status',
platform: 'linux'
})
).toBe(false)
expect(env).toEqual({ PATH: '/usr/bin' })
})
it('guards explicitly marked automation and consumes its internal marker', () => {
const env: Record<string, string> = {
PATH: '/usr/bin',
[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard'
}
expect(
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
platform: 'linux'
})
).toBe(true)
expectGuarded(env)
expect(env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBeUndefined()
})
it('preserves caller askpass and indexed config when appending the guard', () => {
const env: Record<string, string> = {
GIT_ASKPASS: '/usr/local/bin/user-askpass',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
}
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
platform: 'linux'
})
expect(env.GIT_ASKPASS).toBe('/usr/local/bin/user-askpass')
expect(env.GIT_CONFIG_COUNT).toBe('3')
expect(env.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(env.GIT_CONFIG_VALUE_0).toBe('http://proxy.invalid')
expect(env.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(env.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
})
it('registers a guarded Windows agent environment for WSL forwarding', () => {
const env: Record<string, string> = { PATH: 'C:\\Windows\\System32' }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
platform: 'win32'
})
const wslenvKeys = (env.WSLENV ?? '').split(':')
expect(wslenvKeys).toContain('GIT_TERMINAL_PROMPT')
expect(wslenvKeys).toContain('GCM_INTERACTIVE')
expect(wslenvKeys).toContain('GIT_CONFIG_COUNT')
expect(wslenvKeys).toContain('GIT_CONFIG_KEY_0')
expect(wslenvKeys).toContain('GIT_CONFIG_VALUE_0')
expect(wslenvKeys).not.toContain('GIT_ASKPASS')
expect(wslenvKeys).not.toContain('SSH_ASKPASS')
})
it('forwards only the guard decision and scalars to a detached host', () => {
const env: Record<string, string> = {
PATH: '/usr/bin',
GIT_ASKPASS: '/usr/local/bin/user-askpass',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.quotePath',
GIT_CONFIG_VALUE_0: 'false'
}
expect(
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
platform: 'win32',
deferGitConfigGuardToHost: true
})
).toBe(true)
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(env.GIT_ASKPASS).toBe('/usr/local/bin/user-askpass')
expect(env.SSH_ASKPASS).toBeUndefined()
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_KEY_0).toBe('core.quotePath')
expect(env.GIT_CONFIG_KEY_1).toBeUndefined()
expect(env.WSLENV).toBeUndefined()
expect(env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBe('guard')
})
it('does not add premature WSL forwarding entries to a detached-host wire env', () => {
const env: Record<string, string> = {
WSLENV: 'CALLER_VALUE/p',
[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard'
}
applyTerminalGitCredentialPromptGuard(env, {
platform: 'win32',
deferGitConfigGuardToHost: true
})
expect(env.WSLENV).toBe('CALLER_VALUE/p')
})
})
@@ -0,0 +1,4 @@
export {
applyTerminalGitCredentialPromptGuard,
TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV
} from '../../shared/terminal-git-credential-guard'
+39
View File
@@ -8428,6 +8428,45 @@ describe('registerWorktreeHandlers', () => {
expect(fsProvider.writeFile).not.toHaveBeenCalled()
})
it('reads an issue-command override from the requested host when repo ids collide', async () => {
const localRepo = {
id: 'repo-shared',
path: '/local/repo',
displayName: 'local',
badgeColor: '#000',
addedAt: 0
}
const sshRepo = {
...localRepo,
path: '/remote/repo',
displayName: 'ssh',
connectionId: 'conn-1'
}
const fsProvider = {
readFile: vi.fn(async (filePath: string) => {
if (filePath.endsWith('/.orca/issue-command')) {
return { content: 'remote command\n', isBinary: false }
}
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
})
}
store.getRepos.mockReturnValue([localRepo, sshRepo])
store.getRepo.mockReturnValue(localRepo)
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
await expect(
handlers['hooks:readIssueCommand'](null, {
repoId: 'repo-shared',
hostId: 'ssh:conn-1'
})
).resolves.toMatchObject({
localContent: 'remote command',
effectiveContent: 'remote command',
source: 'local'
})
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.orca/issue-command')
})
it('creates remote .gitignore only when it is missing while writing SSH issue commands', async () => {
const repo = {
id: 'repo-ssh',
+57 -54
View File
@@ -2248,74 +2248,77 @@ export function registerWorktreeHandlers(
)
})
ipcMain.handle('hooks:readIssueCommand', async (_event, args: { repoId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo || isFolderRepo(repo)) {
return {
status: 'ok',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: '',
source: 'none' as const
}
}
if (repo.connectionId) {
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
const fsProvider = getSshFilesystemProvider(repo.connectionId)
if (!fsProvider) {
ipcMain.handle(
'hooks:readIssueCommand',
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
return {
status: 'error',
status: 'ok',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: issueCommandPath,
localFilePath: '',
source: 'none' as const
}
}
if (repo.connectionId) {
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
const fsProvider = getSshFilesystemProvider(repo.connectionId)
if (!fsProvider) {
return {
status: 'error',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: issueCommandPath,
source: 'none' as const
}
}
let status: 'ok' | 'error' = 'ok'
let localContent: string | null = null
let sharedContent: string | null = null
try {
const result = await fsProvider.readFile(issueCommandPath)
localContent = result.isBinary ? null : result.content.trim() || null
} catch (error) {
if (!isENOENT(error)) {
status = 'error'
let status: 'ok' | 'error' = 'ok'
let localContent: string | null = null
let sharedContent: string | null = null
try {
const result = await fsProvider.readFile(issueCommandPath)
localContent = result.isBinary ? null : result.content.trim() || null
} catch (error) {
if (!isENOENT(error)) {
status = 'error'
}
}
try {
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
sharedContent = result.isBinary
? null
: parseOrcaYaml(result.content)?.issueCommand?.trim() || null
} catch (error) {
if (!isENOENT(error)) {
status = 'error'
}
}
const effectiveContent = localContent ?? sharedContent
return {
status: localContent ? 'ok' : status,
localContent,
sharedContent,
effectiveContent,
localFilePath: issueCommandPath,
source: localContent
? ('local' as const)
: sharedContent
? ('shared' as const)
: ('none' as const)
}
}
try {
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
sharedContent = result.isBinary
? null
: parseOrcaYaml(result.content)?.issueCommand?.trim() || null
} catch (error) {
if (!isENOENT(error)) {
status = 'error'
}
}
const effectiveContent = localContent ?? sharedContent
return {
status: localContent ? 'ok' : status,
localContent,
sharedContent,
effectiveContent,
localFilePath: issueCommandPath,
source: localContent
? ('local' as const)
: sharedContent
? ('shared' as const)
: ('none' as const)
}
return readIssueCommand(repo.path)
}
return readIssueCommand(repo.path)
})
)
ipcMain.handle(
'hooks:writeIssueCommand',
async (_event, args: { repoId: string; content: string }) => {
const repo = store.getRepo(args.repoId)
async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => {
const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
return
}
@@ -429,6 +429,48 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
})
it('drops stale inherited Git config indices behind a smaller explicit count', async () => {
const keys = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_KEY_0',
'GIT_CONFIG_VALUE_0',
'GIT_CONFIG_KEY_1',
'GIT_CONFIG_VALUE_1'
] as const
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
process.env.GIT_CONFIG_COUNT = '2'
process.env.GIT_CONFIG_KEY_0 = 'base.zero'
process.env.GIT_CONFIG_VALUE_0 = 'zero'
process.env.GIT_CONFIG_KEY_1 = 'base.one'
process.env.GIT_CONFIG_VALUE_1 = 'one'
try {
await provider.spawn({
cols: 80,
rows: 24,
env: {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'override.zero',
GIT_CONFIG_VALUE_0: 'override'
}
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('1')
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('override.zero')
expect(spawnEnv.GIT_CONFIG_KEY_1).toBeUndefined()
expect(spawnEnv.GIT_CONFIG_VALUE_1).toBeUndefined()
} finally {
for (const key of keys) {
if (saved[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = saved[key]
}
}
}
})
it('does not inherit AppImage runtime env into Linux PTY shells', async () => {
const saved = {
APPIMAGE: process.env.APPIMAGE,
+10 -3
View File
@@ -58,6 +58,7 @@ 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'
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -296,7 +297,13 @@ export type LocalPtyProviderOptions = {
buildSpawnEnv?: (
id: string,
baseEnv: Record<string, string>,
ctx?: { command?: string; shellPath?: string; isWsl?: boolean; wslDistro?: string | null }
ctx?: {
command?: string
launchAgent?: PtySpawnOptions['launchAgent']
shellPath?: string
isWsl?: boolean
wslDistro?: string | null
}
) => Record<string, string>
/** Whether worktree-scoped shell history is enabled. When true (or absent)
* and a worktreeId is provided, HISTFILE is scoped per-worktree. */
@@ -469,8 +476,7 @@ export class LocalPtyProvider implements IPtyProvider {
validateWorkingDirectory(validationCwd)
const spawnEnv: Record<string, string> = {
...process.env,
...args.env,
...mergeGitConfigEnvProtocol(process.env, args.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
@@ -522,6 +528,7 @@ export class LocalPtyProvider implements IPtyProvider {
const finalEnv = this.opts.buildSpawnEnv
? this.opts.buildSpawnEnv(id, spawnEnv, {
command: args.command,
launchAgent: args.launchAgent,
shellPath,
isWsl: isWslShell,
wslDistro: launchWslDistro
@@ -67,6 +67,25 @@ describe('SshPtyProvider', () => {
})
})
it('forwards trusted agent identity for wrapped remote commands', async () => {
mux.request.mockResolvedValue({ id: 'pty-agent' })
await provider.spawn({
cols: 120,
rows: 40,
command: 'cd /repo && custom-agent-wrapper',
launchAgent: 'claude'
})
expect(mux.request).toHaveBeenCalledWith(
'pty.spawn',
expect.objectContaining({
command: 'cd /repo && custom-agent-wrapper',
launchAgent: 'claude'
})
)
})
it('forwards pane identity as relay metadata on fresh spawn', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
+1
View File
@@ -154,6 +154,7 @@ export class SshPtyProvider implements IPtyProvider {
// Pi-compatible agent is being launched, while commandDelivery tells it
// whether to submit the command itself for runtime-owned background PTYs.
...(opts.command ? { command: opts.command } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.shellOverride !== undefined ? { shellOverride: opts.shellOverride } : {}),
...(opts.terminalWindowsWslDistro !== undefined
? { terminalWindowsWslDistro: opts.terminalWindowsWslDistro }
+8
View File
@@ -117,6 +117,12 @@ export type PtySpawnResult = {
* writing the snapshot so ANSI cursor positions land correctly. */
snapshotCols?: number
snapshotRows?: number
/** Provider sequence at the attach boundary. `reset` starts a new provider
* generation; `continued` resumes the existing absolute domain. */
providerSequence?: {
value: number
generation: 'continued' | 'reset'
}
/** Kitty keyboard flags persisted in the daemon snapshot, threaded so the
* re-seeded runtime emulator answers hidden `CSI ? u` with the real flags
* (terminal-query-authority.md §kitty). Never replayed into a renderer
@@ -154,6 +160,8 @@ export type PtyProcessInfo = {
export type IPtyProvider = {
spawn(opts: PtySpawnOptions): Promise<PtySpawnResult>
/** Whether this spawn target can append the Git guard after its final env merge. */
supportsGitCredentialGuardHost?: (sessionId?: string) => boolean
attach(id: string): Promise<void>
hasPty?: (id: string) => boolean
write(id: string, data: string): void
+166
View File
@@ -178,6 +178,169 @@ describe('fetchClaudeRateLimits', () => {
)
})
it('falls back to the legacy keychain token when the scoped token is rejected as stale', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir
? JSON.stringify({
claudeAiOauth: { accessToken: 'stale-scoped-token', refreshToken: 'refresh-1' }
})
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
)
netFetchMock.mockImplementation(async (_url: string, init?: RequestInit) => {
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization
return auth === 'Bearer fresh-legacy-token'
? new Response(
JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }),
{ status: 200 }
)
: new Response(
JSON.stringify({ error: { message: 'Invalid authentication credentials' } }),
{ status: 401 }
)
})
await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 12 },
weekly: { usedPercent: 34 }
})
expect(netFetchMock).toHaveBeenCalledTimes(2)
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('prefers a legacy access token over scoped refresh-only credentials', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir
? JSON.stringify({ claudeAiOauth: { refreshToken: 'refresh-only' } })
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 12 }
})
expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(netFetchMock).toHaveBeenCalledWith(
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer fresh-legacy-token' })
})
)
})
it('does not retry with the legacy keychain for managed account credentials', async () => {
const configDir = '/Users/test/managed-account'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: true,
provenance: 'managed:account-1'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-managed-token' } }) : null
)
netFetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), {
status: 401
})
)
await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'error'
})
expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined)
})
it('does not retry with the legacy keychain for WSL targets', async () => {
// Why: a WSL target's stale credentials must never be answered with the
// host user's legacy macOS Keychain account.
const configDir = '\\\\wsl$\\Ubuntu\\home\\test\\.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
runtime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxConfigDir: '/home/test/.claude',
envPatch: { CLAUDE_CONFIG_DIR: '/home/test/.claude' },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir
? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-wsl-token' } })
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
)
netFetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), {
status: 401
})
)
await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'error'
})
expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined)
})
it('skips the legacy retry when the legacy item mirrors the failed scoped token', async () => {
// Why: Claude's usage endpoint has a tight request budget; retrying the
// identical token would double the request for a guaranteed second 401.
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(
JSON.stringify({ claudeAiOauth: { accessToken: 'mirrored-token' } })
)
netFetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), {
status: 401
})
)
await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'error'
})
expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(undefined)
})
it('accepts Claude Code statusline-style rate limit window fields', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
@@ -734,6 +897,9 @@ describe('fetchClaudeRateLimits', () => {
}
})
)
// Legacy item absent — the stale-scoped legacy fallback must not preempt
// CLI repair in this scenario.
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
+102 -25
View File
@@ -158,13 +158,16 @@ async function readFromKeychain(configDir?: string): Promise<OAuthCredentialRead
if (scopedCredentials.token) {
return scopedCredentials
}
if (scopedCredentials.hasRefreshableCredentials) {
return scopedCredentials
}
const legacyCredentials = await readCredentialsFromStrictKeychain(undefined, 'legacy-keychain')
// Why: Orca cannot refresh tokens itself, so an actual access token from
// either item beats refresh-only credentials. A scoped item the CLI stopped
// maintaining must not shadow a still-working legacy token.
if (legacyCredentials.token) {
return legacyCredentials
}
if (scopedCredentials.hasRefreshableCredentials) {
return scopedCredentials
}
if (legacyCredentials.hasRefreshableCredentials) {
return legacyCredentials
}
@@ -637,6 +640,84 @@ async function supplementOAuthUsageFromCli(input: {
}
}
async function completeOAuthUsageSuccess(input: {
oauthLimits: ProviderRateLimits
oauthCredentials: OAuthCredentialReadResult
attempts: ClaudeUsageAttemptState
options?: FetchClaudeRateLimitsOptions
}): Promise<ProviderRateLimits> {
const limits = await supplementOAuthUsageFromCli({
oauthLimits: input.oauthLimits,
authPreparation: input.options?.authPreparation,
oauthCredentials: input.oauthCredentials,
attempts: input.attempts,
networkProxySettings: input.options?.networkProxySettings,
allowUsagePanelSupplement:
input.options?.allowUsagePanelSupplement ??
isManagedClaudeAuth(input.options?.authPreparation),
signal: input.options?.signal
})
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return withClaudeUsageMetadata(
limits,
metadataForAttempt({
attemptedSources: input.attempts.attemptedSources,
oauthCredentials: input.oauthCredentials,
authPreparation: input.options?.authPreparation,
source: 'oauth'
})
)
}
function canRetryWithLegacyKeychainToken(input: {
classification: ClaudeUsageErrorClassification
oauthCredentials: OAuthCredentialReadResult
authPreparation?: ClaudeRuntimeAuthPreparation
}): boolean {
// Why: the CLI only maintains the legacy keychain item for the default config
// dir, so a scoped item can hold a token that expired long ago and will 401
// on every fetch with no recovery path. Host system-default auth may retry
// with the legacy item; managed/WSL credentials must never be answered with
// the host user's legacy keychain account.
return (
input.classification.failureKind === 'stale-token' &&
input.oauthCredentials.source === 'scoped-keychain' &&
(input.authPreparation?.runtime ?? 'host') === 'host' &&
!isManagedClaudeAuth(input.authPreparation)
)
}
async function retryOAuthWithLegacyKeychainToken(input: {
failedToken: string | null
attempts: ClaudeUsageAttemptState
options?: FetchClaudeRateLimitsOptions
}): Promise<ProviderRateLimits | null> {
const legacyCredentials = await readCredentialsFromStrictKeychain(undefined, 'legacy-keychain')
if (!legacyCredentials.token || legacyCredentials.token === input.failedToken) {
return null
}
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
try {
const oauthLimits = await fetchViaOAuth(legacyCredentials.token, input.options?.signal)
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return await completeOAuthUsageSuccess({
oauthLimits,
oauthCredentials: legacyCredentials,
attempts: input.attempts,
options: input.options
})
} catch (err) {
warnClaudeUsageFetchFailure(input.options?.authPreparation, legacyCredentials, err)
return null
}
}
function shouldDeferForLiveClaude(
authPreparation: ClaudeRuntimeAuthPreparation | undefined,
classification: ClaudeUsageErrorClassification
@@ -798,32 +879,28 @@ export async function fetchClaudeRateLimits(
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
const limits = await supplementOAuthUsageFromCli({
oauthLimits,
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
networkProxySettings: options?.networkProxySettings,
allowUsagePanelSupplement:
options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation),
signal: options?.signal
})
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return withClaudeUsageMetadata(
limits,
metadataForAttempt({
attemptedSources: attempts.attemptedSources,
oauthCredentials,
authPreparation: options?.authPreparation,
source: 'oauth'
})
)
return await completeOAuthUsageSuccess({ oauthLimits, oauthCredentials, attempts, options })
} catch (err) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
const classification = classifyClaudeOAuthUsageError(err)
if (
canRetryWithLegacyKeychainToken({
classification,
oauthCredentials,
authPreparation: options?.authPreparation
})
) {
const legacyResult = await retryOAuthWithLegacyKeychainToken({
failedToken: oauthCredentials.token,
attempts,
options
})
if (legacyResult) {
return legacyResult
}
}
if (shouldDeferForLiveClaude(options?.authPreparation, classification)) {
return liveClaudeDeferredResult({
attempts,
@@ -97,6 +97,51 @@ describe('fetchCodexRateLimits auth errors', () => {
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('returns the app-server chatgpt-auth-required error without spawning the PTY probe', async () => {
const rpcChild = makeRpcChild()
const authError = 'chatgpt authentication required to read rate limits'
childSpawnMock.mockReturnValue(rpcChild)
rpcChild.stdin.write.mockImplementation((line: string) => {
const msg = JSON.parse(line) as { id?: number; method?: string }
if (msg.method === 'initialize') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
)
}, 0)
}
if (msg.method === 'account/rateLimits/read') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
error: { code: -32600, message: authError }
})}\n`
)
)
}, 0)
}
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: null,
weekly: null,
status: 'error',
error: authError
})
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('preserves Codex PTY auth errors when the CLI exits before status is available', async () => {
const ptyHandlers: { onData?: (data: string) => void; onExit?: () => void } = {}
const authError =
+84 -2
View File
@@ -107,13 +107,95 @@ describe('fetchGrokRateLimits', () => {
expect(netFetchMock).not.toHaveBeenCalled()
})
it('returns unavailable when billing has no credit usage', async () => {
it('returns unavailable when neither billing view has usage', async () => {
authState.file = freshAuthJson()
netFetchMock.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } }))
netFetchMock
.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } }))
.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } }))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('unavailable')
expect(result.weekly).toBeNull()
expect(result.monthly).toBeUndefined()
})
it('maps monthly included usage for unified-billing accounts without weekly credits', async () => {
authState.file = freshAuthJson()
netFetchMock
.mockResolvedValueOnce(
jsonResponse({
config: {
currentPeriod: {
type: 'USAGE_PERIOD_TYPE_WEEKLY',
start: '2026-07-10T19:38:56.948570+00:00',
end: '2026-07-17T19:38:56.948570+00:00'
},
isUnifiedBillingUser: true,
subscriptionTier: 'SuperGrok'
}
})
)
.mockResolvedValueOnce(
jsonResponse({
config: {
monthlyLimit: { val: 150000 },
used: { val: 837 },
billingPeriodStart: '2026-07-01T00:00:00+00:00',
billingPeriodEnd: '2026-08-01T00:00:00+00:00'
}
})
)
const result = await fetchGrokRateLimits()
expect(result.status).toBe('ok')
expect(result.error).toBeNull()
expect(result.weekly).toBeNull()
expect(result.monthly?.usedPercent).toBeCloseTo((837 / 150000) * 100, 5)
expect(result.monthly?.windowMinutes).toBe(43_200)
expect(result.monthly?.resetsAt).toBe(Date.parse('2026-08-01T00:00:00+00:00'))
expect(result.usageMetadata?.authProvenance).toContain('SuperGrok')
expect(netFetchMock).toHaveBeenNthCalledWith(
2,
'https://cli-chat-proxy.grok.com/v1/billing',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer access-token' })
})
)
})
// Why: 'unavailable' would make applyStalePolicy discard the last good
// monthly snapshot; transient fallback failures must present like transient
// credits-view failures so stale data survives.
it('surfaces an error when the monthly fallback request fails', async () => {
authState.file = freshAuthJson()
netFetchMock
.mockResolvedValueOnce(jsonResponse({ config: { isUnifiedBillingUser: true } }))
.mockResolvedValueOnce(jsonResponse({}, 500))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('error')
expect(result.error).toBe('Grok usage request failed (HTTP 500)')
})
it('surfaces an error when the monthly fallback request throws', async () => {
authState.file = freshAuthJson()
netFetchMock
.mockResolvedValueOnce(jsonResponse({ config: { isUnifiedBillingUser: true } }))
.mockRejectedValueOnce(new Error('network down'))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('error')
expect(result.error).toBe('network down')
})
it('does not request the default billing view when weekly credits are present', async () => {
authState.file = freshAuthJson()
netFetchMock.mockResolvedValueOnce(jsonResponse(BILLING_RESPONSE))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('ok')
expect(netFetchMock).toHaveBeenCalledTimes(1)
})
it('returns unavailable when billing response has no config', async () => {
+114 -29
View File
@@ -12,8 +12,12 @@ const GROK_CLI_PROXY_BASE =
process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim().replace(/\/$/, '') ||
'https://cli-chat-proxy.grok.com/v1'
const BILLING_CREDITS_URL = `${GROK_CLI_PROXY_BASE}/billing?format=credits`
// Why: unified-billing accounts have no weekly credits; their included monthly
// budget is only present in the default (format-less) billing view.
const BILLING_DEFAULT_URL = `${GROK_CLI_PROXY_BASE}/billing`
const API_TIMEOUT_MS = 10_000
const WEEKLY_WINDOW_MINUTES = 10_080
const MONTHLY_WINDOW_MINUTES = 43_200
const GROK_CLI_AUTH_HEADER = 'xai-grok-cli'
@@ -31,6 +35,8 @@ type GrokBillingConfig = {
billingPeriodStart?: string
billingPeriodEnd?: string
subscriptionTier?: string
monthlyLimit?: GrokMoneyVal
used?: GrokMoneyVal
onDemandCap?: GrokMoneyVal
onDemandUsed?: GrokMoneyVal
prepaidBalance?: GrokMoneyVal
@@ -81,6 +87,28 @@ function mapWeeklyCredits(config: GrokBillingConfig): RateLimitWindow | null {
}
}
function parseMoneyVal(value: GrokMoneyVal | undefined): number | null {
const raw = value?.val
const num = typeof raw === 'string' ? Number.parseFloat(raw) : raw
return typeof num === 'number' && Number.isFinite(num) ? num : null
}
function mapMonthlyUsage(config: GrokBillingConfig): RateLimitWindow | null {
const limit = parseMoneyVal(config.monthlyLimit)
const used = parseMoneyVal(config.used)
if (limit === null || used === null || limit <= 0) {
return null
}
const periodEnd = config.currentPeriod?.end ?? config.billingPeriodEnd
const resetsAt = periodEnd ? Date.parse(periodEnd) : null
return {
usedPercent: Math.min(100, Math.max(0, (used / limit) * 100)),
windowMinutes: MONTHLY_WINDOW_MINUTES,
resetsAt: resetsAt !== null && Number.isFinite(resetsAt) ? resetsAt : null,
resetDescription: parseResetDescription(periodEnd)
}
}
function grokRequestHeaders(session: GrokAuthSession): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${session.accessToken}`,
@@ -103,28 +131,22 @@ function resolveBillingConfig(data: GrokBillingResponse): GrokBillingConfig | nu
return null
}
function mapBillingResponse(
data: GrokBillingResponse,
function billingUsageResult(
windows: { weekly?: RateLimitWindow | null; monthly?: RateLimitWindow | null },
config: GrokBillingConfig,
session: GrokAuthSession
): ProviderRateLimits {
const config = resolveBillingConfig(data)
// Why: a 200 without credit usage means the plan has no weekly credits —
// 'unavailable' hides the bar (like Claude on API-key billing); 'error'
// would paint a permanent alert for a signed-in account that has no quota.
if (!config) {
return result('unavailable', 'Grok billing response did not include config')
}
const weekly = mapWeeklyCredits(config)
const tier = config.subscriptionTier?.trim()
const authLabel = session.email?.trim() || session.userId || 'Grok account'
const provenance = tier ? `${authLabel} (${tier})` : authLabel
return {
provider: 'grok',
session: null,
weekly,
weekly: windows.weekly ?? null,
...(windows.monthly ? { monthly: windows.monthly } : {}),
updatedAt: Date.now(),
error: weekly ? null : 'Grok billing response did not include credit usage',
status: weekly ? 'ok' : 'unavailable',
error: null,
status: 'ok',
usageMetadata: {
source: 'oauth',
authProvenance: provenance
@@ -132,6 +154,61 @@ function mapBillingResponse(
}
}
type GrokBillingFetchOutcome =
| { kind: 'data'; data: GrokBillingResponse }
| { kind: 'result'; result: ProviderRateLimits }
async function fetchBillingData(
url: string,
session: GrokAuthSession,
signal?: AbortSignal
): Promise<GrokBillingFetchOutcome> {
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)])
: AbortSignal.timeout(API_TIMEOUT_MS)
const res = await net.fetch(url, {
headers: grokRequestHeaders(session),
signal: requestSignal
})
if (res.status === 401 || res.status === 403) {
return {
kind: 'result',
result: result('error', `Grok usage request unauthorized (HTTP ${res.status})`)
}
}
if (!res.ok) {
return {
kind: 'result',
result: result('error', `Grok usage request failed (HTTP ${res.status})`)
}
}
const data: unknown = await res.json()
return {
kind: 'data',
data: typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {}
}
}
type GrokMonthlyFallbackOutcome =
| { kind: 'window'; window: RateLimitWindow | null }
| { kind: 'result'; result: ProviderRateLimits }
// Why: request failures propagate as 'error' (thrown errors reach the caller's
// catch) so the stale policy keeps the last good monthly snapshot — the
// 'unavailable' status would discard it. Only a successful response without
// monthly fields means the account truly has no visible quota.
async function fetchMonthlyUsageFallback(
session: GrokAuthSession,
signal?: AbortSignal
): Promise<GrokMonthlyFallbackOutcome> {
const outcome = await fetchBillingData(BILLING_DEFAULT_URL, session, signal)
if (outcome.kind === 'result') {
return outcome
}
const config = outcome.data.config ?? outcome.data
return { kind: 'window', window: mapMonthlyUsage(config) }
}
// Why: Orca never runs grok login; it only reads the session file the CLI updates.
export async function fetchGrokRateLimits(
options: { signal?: AbortSignal; authReadResult?: GrokAuthReadResult } = {}
@@ -152,24 +229,32 @@ export async function fetchGrokRateLimits(
}
try {
const signal = options.signal
? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)])
: AbortSignal.timeout(API_TIMEOUT_MS)
const res = await net.fetch(BILLING_CREDITS_URL, {
headers: grokRequestHeaders(session),
signal
})
if (res.status === 401 || res.status === 403) {
return result('error', `Grok usage request unauthorized (HTTP ${res.status})`)
const outcome = await fetchBillingData(BILLING_CREDITS_URL, session, options.signal)
if (outcome.kind === 'result') {
return outcome.result
}
if (!res.ok) {
return result('error', `Grok usage request failed (HTTP ${res.status})`)
const config = resolveBillingConfig(outcome.data)
// Why: a 200 without credit usage means the plan has no weekly credits —
// 'unavailable' hides the bar (like Claude on API-key billing); 'error'
// would paint a permanent alert for a signed-in account that has no quota.
if (!config) {
return result('unavailable', 'Grok billing response did not include config')
}
const data: unknown = await res.json()
return mapBillingResponse(
typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {},
session
)
const weekly = mapWeeklyCredits(config)
if (weekly) {
return billingUsageResult({ weekly }, config, session)
}
// Why: unified-billing accounts report a monthly included-usage budget
// instead of weekly credits; the credits view omits creditUsagePercent
// for them, so read the default billing view before giving up.
const fallback = await fetchMonthlyUsageFallback(session, options.signal)
if (fallback.kind === 'result') {
return fallback.result
}
if (fallback.window) {
return billingUsageResult({ monthly: fallback.window }, config, session)
}
return result('unavailable', 'Grok billing response did not include credit usage')
} catch (err) {
return result('error', err instanceof Error ? err.message : 'Grok usage request failed')
}
+230 -1
View File
@@ -120,6 +120,19 @@ function unavailableProvider(
}
}
// Why: beforeEach caches snapshot objects whose updatedAt is pinned at suite
// start, so after 5 fake minutes every healthy provider looks stale and every
// activation degrades to a full fetch. Backoff tests that reason about the
// individual retry lane need healthy providers minted fresh at fetch time.
function mockFreshBackgroundProviderFetches(): void {
vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 24))
vi.mocked(fetchGeminiRateLimits).mockImplementation(async () => okProvider('gemini', 0))
vi.mocked(fetchOpenCodeGoRateLimits).mockImplementation(async () => okProvider('opencode-go', 0))
vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0))
vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0))
vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok'))
}
function serviceInternals(service: RateLimitService): { fetchAll: () => Promise<void> } {
return service as unknown as { fetchAll: () => Promise<void> }
}
@@ -393,7 +406,7 @@ describe('RateLimitService', () => {
}
})
it('throttles repeated active-window retries while Claude is still failing', async () => {
it('backs off repeated active-window retries while Claude is still failing', async () => {
vi.useFakeTimers()
try {
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(errorProvider('claude', 'still failing'))
@@ -407,6 +420,7 @@ describe('RateLimitService', () => {
await vi.advanceTimersByTimeAsync(1000)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
// First activation recovers immediately (retry timestamps start at 0).
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
@@ -415,11 +429,175 @@ describe('RateLimitService', () => {
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
// Two consecutive failures: the retry window doubled to 60s, so an
// activation at +30s must not hammer the endpoint again.
await vi.advanceTimersByTimeAsync(30 * 1000)
window.emit('restore')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(30 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3)
// Three consecutive failures: 120s window.
await vi.advanceTimersByTimeAsync(60 * 1000)
window.emit('show')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(60 * 1000)
window.emit('restore')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(4)
service.stop()
} finally {
vi.useRealTimers()
}
})
it('resets the retry backoff once Claude recovers', async () => {
vi.useFakeTimers()
try {
vi.mocked(fetchClaudeRateLimits)
.mockResolvedValueOnce(errorProvider('claude', 'still failing'))
.mockResolvedValueOnce(errorProvider('claude', 'still failing'))
.mockResolvedValueOnce(okProvider('claude', 12))
.mockResolvedValue(errorProvider('claude', 'failing again'))
mockFreshBackgroundProviderFetches()
const service = new RateLimitService()
const window = new FakeRateLimitWindow()
service.attach(asRateLimitWindow(window))
service.start({ fetchImmediately: false })
await vi.advanceTimersByTimeAsync(1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
// Recovery fetch succeeds and must clear the failure streak.
await vi.advanceTimersByTimeAsync(60 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3)
expect(service.getState().claude?.status).toBe('ok')
// The stale-ok snapshot forces a full refresh, which fails again.
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(4)
expect(service.getState().claude?.status).toBe('error')
// Consume the stale active-retry timestamp so the next windows measure
// the post-recovery streak rather than time elapsed before recovery.
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(5)
// Post-recovery streak is 2 (not the pre-recovery 4): the window must be
// 60s, so +30s stays throttled and +60s retries.
await vi.advanceTimersByTimeAsync(30 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(5)
await vi.advanceTimersByTimeAsync(30 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(6)
service.stop()
} finally {
vi.useRealTimers()
}
})
it('counts a stale-driven full fetch as the failing provider retry attempt', async () => {
vi.useFakeTimers()
try {
vi.mocked(fetchClaudeRateLimits).mockImplementation(async () =>
errorProvider('claude', 'still failing')
)
mockFreshBackgroundProviderFetches()
const service = new RateLimitService()
const window = new FakeRateLimitWindow()
service.attach(asRateLimitWindow(window))
service.start({ fetchImmediately: false })
await vi.advanceTimersByTimeAsync(1000)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
// Healthy providers go stale after 5 minutes, so this activation runs a
// full fetch that also retries failing Claude (streak now 3 → 120s).
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3)
// Why: the full fetch was itself a retry. An activation moments later
// must not fire the individual failure lane ahead of the backoff window.
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3)
// Once the 120s window elapses, the individual retry lane fires again.
await vi.advanceTimersByTimeAsync(120 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(4)
service.stop()
} finally {
vi.useRealTimers()
}
})
it('keeps a settled error chip settled during background refetches instead of flashing fetching', async () => {
vi.useFakeTimers()
try {
const secondClaude = deferred<ProviderRateLimits>()
vi.mocked(fetchClaudeRateLimits)
.mockResolvedValueOnce(errorProvider('claude', 'still failing'))
.mockImplementationOnce(() => secondClaude.promise)
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24))
const service = new RateLimitService()
const window = new FakeRateLimitWindow()
const claudeStatuses: string[] = []
service.onStateChange((state) => {
if (state.claude) {
claudeStatuses.push(state.claude.status)
}
})
service.attach(asRateLimitWindow(window))
service.start({ fetchImmediately: false })
await vi.advanceTimersByTimeAsync(1000)
expect(service.getState().claude?.status).toBe('error')
// Why: the retry must not repaint the settled error chip as a loading
// "…" chip while the refetch is in flight — that is the flash users see
// every cycle when a provider is stuck failing.
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(service.getState().claude?.status).toBe('error')
secondClaude.resolve(okProvider('claude', 12))
await vi.advanceTimersByTimeAsync(0)
expect(service.getState().claude?.status).toBe('ok')
const statusesAfterFirstSettle = claudeStatuses.slice(claudeStatuses.indexOf('error'))
expect(statusesAfterFirstSettle).not.toContain('fetching')
service.stop()
} finally {
vi.useRealTimers()
@@ -586,6 +764,39 @@ describe('RateLimitService', () => {
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
})
it('does not refetch fresh provider data for replayed mobile subscriptions', async () => {
const service = new RateLimitService()
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10))
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20))
await service.refreshIfStale()
await service.refreshIfStale()
await service.refreshIfStale()
expect(fetchClaudeRateLimits).toHaveBeenCalledOnce()
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
})
it('does not queue a follow-up fetch when a mobile subscription replays mid-fetch', async () => {
const service = new RateLimitService()
const claude = deferred<ProviderRateLimits>()
const codex = deferred<ProviderRateLimits>()
vi.mocked(fetchClaudeRateLimits).mockReturnValue(claude.promise)
vi.mocked(fetchCodexRateLimits).mockReturnValue(codex.promise)
const firstRefresh = service.refreshIfStale()
await Promise.resolve()
const replayedRefresh = service.refreshIfStale()
claude.resolve(okProvider('claude', 10))
codex.resolve(okProvider('codex', 20))
await firstRefresh
await replayedRefresh
expect(fetchClaudeRateLimits).toHaveBeenCalledOnce()
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
})
it('waits for a queued explicit refresh when another fetch is already in flight', async () => {
const service = new RateLimitService()
const firstClaude = deferred<ProviderRateLimits>()
@@ -1107,6 +1318,24 @@ describe('RateLimitService', () => {
)
})
it('does not start overlapping inactive Claude preview fetches', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
service.setInactiveClaudeAccountsResolver(() => [
{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
])
vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise)
const firstFetch = service.fetchInactiveClaudeAccountsOnOpen()
await Promise.resolve()
await service.fetchInactiveClaudeAccountsOnOpen()
expect(fetchManagedAccountUsage).toHaveBeenCalledTimes(1)
accountFetch.resolve(okProvider('claude', 50, Date.now()))
await firstFetch
})
it('does not start overlapping inactive Codex preview fetches', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
+99 -1
View File
@@ -77,6 +77,12 @@ const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never crea
const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms.
const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts
const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS
// Why: a persistent failure (bad auth, unsupported plan) retried at the 30s
// floor hammers provider endpoints — Claude's tight-budget usage endpoint
// starts returning 429s — without ever recovering. Back off per consecutive
// failure, capped at the background poll cadence.
const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS
const MAX_ACTIVE_FAILURE_STREAK = 8
// Why: these providers have a dedicated fetch cycle, so an activation retry can
// refresh just the failing one. Providers without one force a full fetchAll, so
// their error retries stay on the 5-minute cadence to protect Claude's budget.
@@ -152,6 +158,18 @@ export class RateLimitService {
grok: 0,
antigravity: 0
}
// Why: consecutive applied failures per provider drive exponential backoff of
// the fast activation-retry lane; reset on any successful/unavailable result.
private activeFailureStreakByProvider: Record<ActiveRateLimitProvider, number> = {
claude: 0,
codex: 0,
gemini: 0,
'opencode-go': 0,
kimi: 0,
minimax: 0,
grok: 0,
antigravity: 0
}
private mainWindow: BrowserWindow | null = null
private detachWindowListeners: (() => void) | null = null
private isFetching = false
@@ -329,6 +347,14 @@ export class RateLimitService {
return this.getState()
}
async refreshIfStale(): Promise<RateLimitState> {
// Why: reconnecting mobile subscribers need fresh backgrounded-desktop data,
// but replaying a subscription must not queue another forced provider fetch.
const plan = this.getActiveWindowRefreshPlan(Date.now())
await this.runActiveWindowRefreshPlan(plan)
return this.getState()
}
async refreshGrok(): Promise<RateLimitState> {
await this.fetchGrokOnly({ force: true })
return this.getState()
@@ -358,6 +384,8 @@ export class RateLimitService {
}
this.codexFetchTarget = nextTarget
this.codexFetchGeneration += 1
// Why: a new account/target starts with a clean retry schedule.
this.activeFailureStreakByProvider.codex = 0
this.inactiveCodexAccountsGeneration += 1
this.pruneInactiveCodexState()
this.lastInactiveCodexFetchAt = 0
@@ -377,6 +405,7 @@ export class RateLimitService {
const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget)
this.codexFetchTarget = nextTarget
this.codexFetchGeneration += 1
this.activeFailureStreakByProvider.codex = 0
this.updateState({
...this.state,
codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex')
@@ -426,6 +455,8 @@ export class RateLimitService {
this.inactiveClaudeAccountsGeneration += 1
this.pruneInactiveClaudeState()
this.claudeFetchGeneration += 1
// Why: a new account/target starts with a clean retry schedule.
this.activeFailureStreakByProvider.claude = 0
this.lastInactiveClaudeFetchAt = 0
this.updateState({
...this.state,
@@ -440,6 +471,7 @@ export class RateLimitService {
const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget)
this.claudeFetchTarget = nextTarget
this.claudeFetchGeneration += 1
this.activeFailureStreakByProvider.claude = 0
this.updateState({
...this.state,
claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude')
@@ -453,6 +485,9 @@ export class RateLimitService {
return
}
this.pruneInactiveClaudeState()
if (this.inactiveClaudeFetching.size > 0) {
return
}
const accounts = this.inactiveClaudeAccountsResolver?.() ?? []
if (accounts.length === 0) {
return
@@ -764,7 +799,11 @@ export class RateLimitService {
if (limits.status === 'error') {
const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider]
const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider)
? ACTIVE_FAILURE_REFETCH_MS
? Math.min(
ACTIVE_FAILURE_REFETCH_MS *
2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1),
MAX_ACTIVE_FAILURE_REFETCH_MS
)
: MIN_REFETCH_MS
if (now - lastRetryAt >= throttleMs) {
retryableFailures.push(provider)
@@ -783,6 +822,18 @@ export class RateLimitService {
return
}
if (plan.kind === 'full') {
// Why: a full fetch retries failing providers too. Restart their retry
// clocks so the individual failure lane doesn't fire again right after,
// ahead of its backoff window. Skip when a fetch is already in flight —
// fetchAll would no-op and the throttle must not be consumed for free.
if (!this.isFetching) {
const now = Date.now()
for (const { provider, limits } of this.getActiveProviderState()) {
if (limits?.status === 'error') {
this.lastActiveFailureRetryAtByProvider[provider] = now
}
}
}
await this.fetchAll()
return
}
@@ -1242,6 +1293,22 @@ export class RateLimitService {
}
}
private trackActiveFailureStreak(
provider: ActiveRateLimitProvider,
fresh: ProviderRateLimits
): void {
if (fresh.status === 'error') {
this.activeFailureStreakByProvider[provider] = Math.min(
this.activeFailureStreakByProvider[provider] + 1,
MAX_ACTIVE_FAILURE_STREAK
)
return
}
if (fresh.status === 'ok' || fresh.status === 'unavailable') {
this.activeFailureStreakByProvider[provider] = 0
}
}
private withFetchingStatus(
current: ProviderRateLimits | null,
provider:
@@ -1264,6 +1331,13 @@ export class RateLimitService {
status: 'fetching'
}
}
// Why: repainting a settled chip as "fetching" on every background refetch
// makes the status bar flash "…" → error each retry cycle when a provider
// is persistently failing. Keep the settled state visible until the new
// result lands; only providers with no settled state show a loading chip.
if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') {
return current
}
return { ...current, status: 'fetching' }
}
@@ -1481,6 +1555,22 @@ export class RateLimitService {
const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration
const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration
if (shouldApplyClaude) {
this.trackActiveFailureStreak('claude', claude)
}
if (shouldApplyCodex) {
this.trackActiveFailureStreak('codex', codex)
}
this.trackActiveFailureStreak('gemini', gemini)
this.trackActiveFailureStreak('antigravity', antigravity)
if (shouldApplyOpencode) {
this.trackActiveFailureStreak('opencode-go', opencodeGo)
}
this.trackActiveFailureStreak('kimi', kimi)
if (shouldApplyMiniMax) {
this.trackActiveFailureStreak('minimax', miniMax)
}
// Why: account switches can race in-flight Codex fetches. Only apply a
// Codex result if both the selected-account provenance and the request
// generation still match, otherwise an old account could overwrite the
@@ -1523,6 +1613,7 @@ export class RateLimitService {
error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error',
status: 'error'
} satisfies ProviderRateLimits)
this.trackActiveFailureStreak('grok', grok)
this.updateState({
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok)
@@ -1575,6 +1666,9 @@ export class RateLimitService {
const shouldApplyCodex =
codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance
if (shouldApplyCodex) {
this.trackActiveFailureStreak('codex', codex)
}
this.updateState({
...this.state,
codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex
@@ -1630,6 +1724,9 @@ export class RateLimitService {
claudeProvenance === latestClaudeProvenance &&
this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget)
if (shouldApplyClaude) {
this.trackActiveFailureStreak('claude', claude)
}
this.updateState({
...this.state,
claude: shouldApplyClaude
@@ -1669,6 +1766,7 @@ export class RateLimitService {
return
}
this.trackActiveFailureStreak('grok', grok)
this.updateState({
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok)
+579
View File
@@ -7117,6 +7117,153 @@ describe('OrcaRuntimeService', () => {
expect(runtime.getPtyOutputSequence('pty-1')).toBe(0)
})
it('aligns a restored session and pre-response bytes to the provider sequence', async () => {
const { runtime } = createSideEffectRuntime()
// Simulate a stream-socket byte winning the race with the spawn response.
runtime.onPtyData('pty-restored', 'queued', 100)
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-restored',
{ value: 900, generation: 'continued' },
0
)
).toBe(906)
runtime.onPtyData('pty-restored', 'fresh', 101)
expect(runtime.getPtyOutputSequence('pty-restored')).toBe(911)
await expect(runtime.serializeMainTerminalBuffer('pty-restored')).resolves.toMatchObject({
data: expect.stringContaining('queuedfresh'),
seq: 911,
source: 'headless'
})
})
it('does not double a fresh daemon sequence on same-main reattach', () => {
const { runtime } = createSideEffectRuntime()
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-fresh',
{ value: 0, generation: 'reset' },
0
)
).toBe(0)
runtime.onPtyData('pty-fresh', 'fresh output', 100)
const sequenceBeforeReattach = runtime.getPtyOutputSequence('pty-fresh')
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-fresh',
{ value: sequenceBeforeReattach, generation: 'continued' },
sequenceBeforeReattach
)
).toBe(sequenceBeforeReattach)
})
it('does not jump ahead of delayed bytes covered by a reattach snapshot', () => {
const { runtime } = createSideEffectRuntime()
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-delayed',
{ value: 0, generation: 'reset' },
0
)
runtime.onPtyData('pty-delayed', 'before', 100)
const sequenceBeforeReattach = runtime.getPtyOutputSequence('pty-delayed')
const delayedCoveredBytes = 'queued'
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-delayed',
{
value: sequenceBeforeReattach + delayedCoveredBytes.length,
generation: 'continued'
},
sequenceBeforeReattach
)
).toBe(sequenceBeforeReattach)
runtime.onPtyData('pty-delayed', delayedCoveredBytes, 101)
expect(runtime.getPtyOutputSequence('pty-delayed')).toBe(
sequenceBeforeReattach + delayedCoveredBytes.length
)
})
it('retains bytes emitted before a fresh daemon spawn resolves', async () => {
const { runtime } = createSideEffectRuntime()
const earlyOutput = '\x1b]0;Fresh shell\x07early prompt'
runtime.onPtyData('pty-fresh', earlyOutput, 100)
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-fresh',
{ value: 0, generation: 'reset' },
0
)
).toBe(earlyOutput.length)
await expect(runtime.serializeMainTerminalBuffer('pty-fresh')).resolves.toMatchObject({
data: expect.stringContaining('early prompt'),
lastTitle: 'Fresh shell',
seq: earlyOutput.length
})
})
it('drops stale headless state without rewinding the runtime sequence', async () => {
const { runtime } = createSideEffectRuntime()
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-restarted',
{ value: 0, generation: 'reset' },
0
)
runtime.onPtyData('pty-restarted', 'old generation', 100)
const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-restarted')
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-restarted',
{ value: 0, generation: 'reset' },
sequenceBeforeRespawn
)
).toBe(sequenceBeforeRespawn)
runtime.onPtyData('pty-restarted', 'new generation', 101)
await expect(runtime.serializeMainTerminalBuffer('pty-restarted')).resolves.toMatchObject({
data: expect.not.stringContaining('old generation'),
seq: sequenceBeforeRespawn + 'new generation'.length
})
})
it('keeps active listener sequences monotonic across a daemon reset', () => {
const { runtime } = createSideEffectRuntime()
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-reset',
{ value: 0, generation: 'reset' },
0
)
runtime.onPtyData('pty-reset', 'old', 100)
const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-reset')
const observedSequences: number[] = []
runtime.subscribeToTerminalData('pty-reset', (_data, meta) => {
if (typeof meta?.seq === 'number') {
observedSequences.push(meta.seq)
}
})
runtime.onPtyData('pty-reset', 'early', 101)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-reset',
{ value: 0, generation: 'reset' },
sequenceBeforeRespawn
)
runtime.onPtyData('pty-reset', 'later', 102)
expect(observedSequences).toEqual([
sequenceBeforeRespawn + 'early'.length,
sequenceBeforeRespawn + 'early'.length + 'later'.length
])
})
it('carries the synthetic permission BEL as a bell fact', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
@@ -7217,6 +7364,318 @@ describe('OrcaRuntimeService', () => {
expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent')
})
it('falls back to the provider snapshot for a restored PTY with no mounted renderer', async () => {
const { runtime } = createSideEffectRuntime()
const serializeBuffer = vi.fn()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
const snapshot = await runtime.serializeTerminalBuffer('pty-restored', {
scrollbackRows: 5000
})
expect(serializeBuffer).not.toHaveBeenCalled()
expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-restored', {
scrollbackRows: 5000
})
expect(snapshot).toEqual({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
})
it('falls back to provider history when a mounted renderer has not hydrated yet', async () => {
const { runtime } = createSideEffectRuntime()
const serializeBuffer = vi.fn().mockResolvedValue({
data: '',
cols: 80,
rows: 24
})
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: '',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
seq: 900,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
serializeProviderBuffer,
hasRendererSerializer: () => true
})
const snapshot = await runtime.serializeTerminalBuffer('pty-restored', {
scrollbackRows: 5000
})
expect(serializeBuffer).toHaveBeenCalledOnce()
expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-restored', {
scrollbackRows: 5000
})
expect(snapshot).toMatchObject({
data: '',
scrollbackAnsi: 'restored history\r\n',
source: 'headless'
})
})
it('keeps an empty renderer snapshot when the provider has no retained content', async () => {
const { runtime } = createSideEffectRuntime()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: '',
scrollbackAnsi: '',
cols: 120,
rows: 40,
seq: 0,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer: vi.fn().mockResolvedValue({ data: '', cols: 51, rows: 40 }),
serializeProviderBuffer,
hasRendererSerializer: () => true
})
const snapshot = await runtime.serializeTerminalBuffer('pty-new')
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
expect(snapshot).toMatchObject({ data: '', cols: 51, rows: 40, source: 'renderer' })
})
it('does not let pre-response bytes hide restored provider history', async () => {
const { runtime } = createSideEffectRuntime()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: 'restored history\r\nqueued',
cols: 80,
rows: 24,
seq: 906,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
runtime.onPtyData('pty-restored', 'queued', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-restored',
{ value: 900, generation: 'continued' },
0
)
const snapshot = await runtime.serializeTerminalBuffer('pty-restored', {
scrollbackRows: 5000
})
expect(snapshot?.data).toContain('restored history')
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
})
it('keeps restored provider history authoritative after later live output', async () => {
const { runtime } = createSideEffectRuntime()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: 'restored history\r\nlater output',
cols: 80,
rows: 24,
seq: 912,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-restored',
{ value: 900, generation: 'continued' },
0
)
runtime.onPtyData('pty-restored', 'later output', 100)
const snapshot = await runtime.serializeTerminalBuffer('pty-restored')
expect(snapshot?.data).toContain('restored history')
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
})
it('uses provider alternate-screen state while a partial model is unsafe', async () => {
const { runtime } = createSideEffectRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer: vi.fn().mockResolvedValue({
data: 'restored tui',
cols: 80,
rows: 24,
seq: 903,
source: 'headless',
alternateScreen: true
}),
hasRendererSerializer: () => false
})
runtime.onPtyData('pty-tui', 'tui', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-tui',
{ value: 900, generation: 'continued' },
0
)
await runtime.serializeTerminalBuffer('pty-tui')
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true)
})
it('tracks live alternate-screen transitions after a provider snapshot', async () => {
const { runtime } = createSideEffectRuntime()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: 'restored tui',
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-tui',
{ value: 900, generation: 'continued' },
0
)
await runtime.serializeTerminalBuffer('pty-tui')
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true)
runtime.onPtyData('pty-tui', '\x1b[?1049l', 100)
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(false)
runtime.onPtyData('pty-tui', '\x1b[?1049h', 101)
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true)
})
it('keeps mode transitions that race a provider snapshot response', async () => {
const { runtime } = createSideEffectRuntime()
let resolveProviderSnapshot:
| ((snapshot: {
data: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}) => void)
| undefined
const serializeProviderBuffer = vi.fn(
() =>
new Promise<{
data: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}>((resolve) => {
resolveProviderSnapshot = resolve
})
)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-tui-race',
{ value: 900, generation: 'continued' },
0
)
const snapshotPromise = runtime.serializeTerminalBuffer('pty-tui-race')
await vi.waitFor(() => expect(resolveProviderSnapshot).toBeDefined())
runtime.onPtyData('pty-tui-race', '\x1b[?1049l', 100)
resolveProviderSnapshot?.({
data: 'captured alt screen',
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
await snapshotPromise
expect(runtime.isTerminalAlternateScreen('pty-tui-race')).toBe(false)
})
it('translates reset provider snapshots without retaining the old title', async () => {
const { runtime } = createSideEffectRuntime()
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-replaced',
{ value: 0, generation: 'reset' },
0
)
runtime.onPtyData('pty-replaced', '\x1b]0;Old process\x07old', 100)
const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-replaced')
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer: vi.fn().mockResolvedValue({
data: 'new process',
cols: 80,
rows: 24,
seq: 'new process'.length,
source: 'headless',
lastTitle: 'New process'
}),
hasRendererSerializer: () => false
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-replaced',
{ value: 0, generation: 'reset' },
sequenceBeforeRespawn
)
await expect(runtime.serializeTerminalBuffer('pty-replaced')).resolves.toMatchObject({
lastTitle: 'New process',
seq: sequenceBeforeRespawn + 'new process'.length
})
})
it('prefers the tracked title over the headless emulator lastTitle', async () => {
const { runtime } = createSideEffectRuntime()
syncSinglePty(runtime)
@@ -9792,6 +10251,8 @@ describe('OrcaRuntimeService', () => {
}
})
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ launchAgent: 'claude' }))
const spawnCall = spawn.mock.calls[0]?.[0] as { env?: Record<string, string> } | undefined
const spawnedEnv = spawnCall?.env ?? {}
const spawnedLeafId = spawnedEnv.ORCA_PANE_KEY.slice(`${spawnedEnv.ORCA_TAB_ID}:`.length)
@@ -22062,6 +22523,124 @@ describe('OrcaRuntimeService', () => {
})
})
it('attributes live legacy PTYs from saved layout bindings when their panes are hidden', async () => {
const session = makeWorkspaceSessionWithHeadlessTerminal()
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...session,
tabsByWorktree: {
[TEST_WORKTREE_ID]: session.tabsByWorktree[TEST_WORKTREE_ID]!.map((tab) => ({
...tab,
ptyId: null
}))
}
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: vi.fn(() => true),
kill: vi.fn(() => true),
getForegroundProcess: vi.fn(async () => null),
// Legacy local PTYs have opaque ids and the local provider cannot recover cwd.
listProcesses: vi.fn(async () => [{ id: 'persisted-pty', cwd: '', title: 'shell' }])
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true,
hasAttachedPty: true,
liveTerminalCount: 1
})
})
it('prefers migrated layout ownership over a worktree id frozen in the PTY id', async () => {
const priorWorktreeId = `${TEST_REPO_ID}::/tmp/worktree-before-rename`
const migratedPtyId = `${priorWorktreeId}@@daemon-controller-pty`
const session = makeWorkspaceSessionWithHeadlessTerminal()
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...session,
tabsByWorktree: {
[TEST_WORKTREE_ID]: session.tabsByWorktree[TEST_WORKTREE_ID]!.map((tab) => ({
...tab,
ptyId: null
}))
},
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: migratedPtyId })
}
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: vi.fn(() => true),
kill: vi.fn(() => true),
getForegroundProcess: vi.fn(async () => null),
listProcesses: vi.fn(async () => [{ id: migratedPtyId, cwd: '', title: 'shell' }])
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true,
hasAttachedPty: true,
liveTerminalCount: 1
})
})
it('preserves deferred startup activity before restored terminal panes mount', async () => {
const session = makeWorkspaceSessionWithHeadlessTerminal({
activeWorktreeIdsOnShutdown: [TEST_WORKTREE_ID]
})
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: vi.fn(() => true),
kill: vi.fn(() => true),
getForegroundProcess: vi.fn(async () => null),
listProcesses: vi.fn(async () => [])
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true
})
})
it('marks saved browser tabs as host sidebar activity like desktop', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {},
terminalLayoutsByTabId: {},
browserTabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'browser-1',
worktreeId: TEST_WORKTREE_ID,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
}
})
)
const runtime = new OrcaRuntimeService(runtimeStore as never)
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true
})
})
it('falls back to the path-keyed GitHub cache entry', async () => {
const runtimeStore = {
...store,
+319 -7
View File
@@ -25,6 +25,7 @@ import type {
TerminalSideEffectFact
} from '../../shared/terminal-side-effect-facts'
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
import {
AGENT_STATUS_STALE_AFTER_MS,
isFreshNonDoneAgentStatus,
@@ -54,7 +55,7 @@ import {
AGENT_PROMPT_SUBMIT_DELAY_MS,
buildAgentPromptPasteBytes
} from '../../shared/agent-prompt-injection'
import { gitExecFileAsync, gitSpawn } from '../git/runner'
import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner'
import { runWithGitReadCacheInvalidation } from '../git/status'
import {
cleanupClaimedCloneTarget,
@@ -351,6 +352,7 @@ import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher'
import type { EmulatorBridge } from '../emulator/emulator-bridge'
import { RuntimeFileCommands } from './orca-runtime-files'
import { RuntimeGitCommands } from './orca-runtime-git'
import type { PtyProviderBufferSnapshot } from '../providers/types'
import { ClaudeAgentTeamsService } from './claude-agent-teams-service'
import type {
AgentTeamsTmuxCompatRequest,
@@ -1179,6 +1181,8 @@ type RuntimeHeadlessTerminal = {
type HeadlessSeedMetadata = {
cwd?: string | null
oscLinks?: TerminalOscLinkRange[]
/** Cold restore history must outrank a model that only saw new-generation bytes. */
preferProviderIfExisting?: boolean
/** Persisted kitty flags from the daemon snapshot, re-applied to the fresh
* emulator so hidden `CSI ? u` answers the real flags instead of ?0u
* (terminal-query-authority.md §kitty). */
@@ -1191,6 +1195,7 @@ type RuntimePtyController = {
rows: number
cwd?: string
command?: string
launchAgent?: TuiAgent
commandDelivery?: 'renderer' | 'provider'
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
env?: Record<string, string>
@@ -1218,6 +1223,11 @@ type RuntimePtyController = {
ptyId: string,
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null>
/** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */
serializeProviderBuffer?(
ptyId: string,
opts?: { scrollbackRows?: number }
): Promise<PtyProviderBufferSnapshot | null>
// Why: synchronous probe used by maybeHydrateHeadlessFromRenderer to skip
// hydration when no renderer is authoritative for this PTY. See
// docs/mobile-prefer-renderer-scrollback.md.
@@ -2243,6 +2253,14 @@ export class OrcaRuntimeService {
private titleObservationSequence = 0
private headlessTerminals = new Map<string, RuntimeHeadlessTerminal>()
private ptyOutputSequenceById = new Map<string, number>()
private providerSequenceInitializedPtys = new Set<string>()
private providerSequenceOffsetByPtyId = new Map<string, number>()
private providerSnapshotPreferredPtys = new Set<string>()
private providerModeTrackersByPtyId = new Map<string, TerminalKittyKeyboardModeTracker>()
private providerModeSnapshotScansByPtyId = new Map<
string,
Set<TerminalKittyKeyboardModeTracker>
>()
private recentPtyPathCandidatesById = new Map<string, string[]>()
// Why: OSC 9999 status can span PTY chunks. Keeping parser state in the
// runtime lets hidden/model-owned terminals observe agent state without a
@@ -5679,6 +5697,10 @@ export class OrcaRuntimeService {
onPtyData(ptyId: string, data: string, at: number, sequenceChars = data.length): number {
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars
this.ptyOutputSequenceById.set(ptyId, outputSequence)
this.providerModeTrackersByPtyId.get(ptyId)?.scan(data)
for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) {
tracker.scan(data)
}
const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data)
const cwd = osc7Metadata.cwd
const cwdChanged = osc7Metadata.cwdChanged
@@ -6403,6 +6425,29 @@ export class OrcaRuntimeService {
this.ptyTitleTrackersByPtyId.delete(ptyId)
}
private resetTrackedTerminalStateForProviderGeneration(ptyId: string): void {
// Why: a replacement daemon session can reuse the PTY id, but title/parser
// state from the prior process must not bleed into its snapshots or chunks.
this.disposePtyTitleTracker(ptyId)
this.oscTitleScanTailByPtyId.delete(ptyId)
this.osc7ScanTailByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
const pty = this.ptysById.get(ptyId)
if (pty) {
pty.lastOscTitle = null
pty.lastOscTitleAt = null
pty.lastAgentStatus = null
pty.managementTitle = null
pty.managementTitleAt = null
}
for (const leaf of this.getLeavesForPty(ptyId)) {
leaf.lastOscTitle = null
leaf.lastOscTitleAt = null
leaf.lastAgentStatus = null
}
this.clearAgentRowSnapshotsForPty(ptyId)
}
private setTerminalSideEffectConsumerAvailable(available: boolean): void {
const nextAvailable = available && this.onTerminalSideEffects !== null
if (nextAvailable === this.terminalSideEffectConsumerAvailable) {
@@ -6593,6 +6638,80 @@ export class OrcaRuntimeService {
return this.ptyOutputSequenceById.get(ptyId) ?? 0
}
synchronizePtyOutputSequenceFromProvider(
ptyId: string,
providerSequence: { value: number; generation: 'continued' | 'reset' },
runtimeSequenceAtSpawnStart = 0
): number {
if (
!Number.isFinite(providerSequence.value) ||
providerSequence.value < 0 ||
!Number.isFinite(runtimeSequenceAtSpawnStart) ||
runtimeSequenceAtSpawnStart < 0
) {
return this.getPtyOutputSequence(ptyId)
}
const baseline = Math.floor(providerSequence.value)
const currentSequence = this.getPtyOutputSequence(ptyId)
const sequenceAtSpawnStart = Math.min(currentSequence, Math.floor(runtimeSequenceAtSpawnStart))
const postSpawnSequence = currentSequence - sequenceAtSpawnStart
const wasInitialized = this.providerSequenceInitializedPtys.has(ptyId)
const replacesExistingRuntimeGeneration = wasInitialized || sequenceAtSpawnStart > 0
const providerOffset =
providerSequence.generation === 'reset'
? sequenceAtSpawnStart
: (this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0)
const providerBaseline = providerOffset + baseline
if (providerSequence.generation === 'reset') {
// Why: daemon respawn/cold restore starts a new absolute domain. Old
// emulator state cannot remain authoritative over the replacement.
if (replacesExistingRuntimeGeneration) {
this.disposeHeadlessTerminal(ptyId)
}
this.providerModeTrackersByPtyId.delete(ptyId)
if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) {
this.resetTrackedTerminalStateForProviderGeneration(ptyId)
}
}
const synchronizedSequence =
providerSequence.generation === 'reset'
? currentSequence
: wasInitialized
? currentSequence
: providerBaseline + postSpawnSequence
this.ptyOutputSequenceById.set(ptyId, synchronizedSequence)
this.providerSequenceInitializedPtys.add(ptyId)
this.providerSequenceOffsetByPtyId.set(ptyId, providerOffset)
const snapshotMayCoverMissingState =
(providerSequence.generation === 'continued' && !wasInitialized) ||
(postSpawnSequence > 0 &&
providerSequence.generation === 'reset' &&
replacesExistingRuntimeGeneration) ||
(providerSequence.generation === 'continued' &&
wasInitialized &&
providerBaseline > currentSequence)
if (snapshotMayCoverMissingState) {
// Why: bytes can cross the control/stream sockets around attach. Until a
// full renderer/provider snapshot is available, a partial model is unsafe.
this.providerSnapshotPreferredPtys.add(ptyId)
} else if (providerSequence.generation === 'reset') {
this.providerSnapshotPreferredPtys.delete(ptyId)
}
const headless = this.headlessTerminals.get(ptyId)
if (headless && !wasInitialized && providerSequence.generation === 'continued') {
// Why: daemon bytes can reach main just before spawn resolves. Queue the
// baseline behind those writes so their emulator sequence is rebased too.
headless.writeChain = headless.writeChain.then(() => {
headless.outputSequence = synchronizedSequence
})
}
return synchronizedSequence
}
subscribeToTerminalData(
ptyId: string,
listener: (data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void
@@ -6769,7 +6888,8 @@ export class OrcaRuntimeService {
// Why: hidden-output recovery is initiated by the desktop renderer. If the
// runtime has not built headless state yet, the mounted xterm is still the
// best available state and avoids a false "snapshot unavailable" result.
return this.serializeRendererTerminalBuffer(ptyId, opts)
const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts)
return rendererSnapshot ?? this.serializeProviderTerminalBuffer(ptyId, opts)
}
async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> {
@@ -6794,10 +6914,17 @@ export class OrcaRuntimeService {
// scrollback to mobile so it rewraps at the new cols, but alternate-screen
// TUIs (vim, Claude Code) own their repaint and have no scrollback — for
// those the mobile client just resizes xterm geometry and consumes the
// TUI's own redraw, so the resize re-stream must be skipped. Returns false
// when there is no headless emulator (resize falls back to geometry-only).
// TUI's own redraw, so the resize re-stream must be skipped. Provider state
// covers restored PTYs whose main-side emulator is only a partial suffix.
isTerminalAlternateScreen(ptyId: string): boolean {
return this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? false
if (this.providerSnapshotPreferredPtys.has(ptyId)) {
return this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? false
}
return (
this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ??
this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ??
false
)
}
// Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch
@@ -6821,10 +6948,14 @@ export class OrcaRuntimeService {
if (existing) {
// Why: emulator already has live data — re-seeding would duplicate
// every byte. The seed is only valid when the emulator is fresh.
if (metadata.preferProviderIfExisting) {
this.providerSnapshotPreferredPtys.add(ptyId)
}
return
}
const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
state.outputSequence = this.getPtyOutputSequence(ptyId)
this.headlessTerminals.set(ptyId, state)
this.recordOsc7MetadataForPty(ptyId, data)
this.recordRecentPtyOutputForPathProvenance(ptyId, data)
@@ -6846,6 +6977,7 @@ export class OrcaRuntimeService {
if (metadata.oscLinks !== undefined) {
state.emulator.setRestoredOscLinks(metadata.oscLinks)
}
this.providerSnapshotPreferredPtys.delete(ptyId)
})
.catch(() => {
// Seeding is best-effort; live data will continue to populate the
@@ -6863,7 +6995,8 @@ export class OrcaRuntimeService {
if (this.headlessHydrationState.has(ptyId)) {
return
}
if (this.headlessTerminals.has(ptyId)) {
const providerSnapshotPreferred = this.providerSnapshotPreferredPtys.has(ptyId)
if (this.headlessTerminals.has(ptyId) && !providerSnapshotPreferred) {
// Daemon-snapshot seed already populated the emulator — skip hydration.
this.headlessHydrationState.set(ptyId, 'done')
return
@@ -6878,11 +7011,18 @@ export class OrcaRuntimeService {
return
}
if (providerSnapshotPreferred) {
// Why: a stream byte can create a partial model before restored history
// arrives. A mounted renderer snapshot can safely replace that model.
this.disposeHeadlessTerminal(ptyId)
}
this.headlessHydrationState.set(ptyId, 'pending')
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
// Why: hydration writes below never set forwardQueryReplies (main-side
// replay guard) — renderer-buffer snapshots can embed stale queries.
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
state.outputSequence = this.getPtyOutputSequence(ptyId)
this.headlessTerminals.set(ptyId, state)
// Why: append the seed work to writeChain so live writes queued by
@@ -6921,6 +7061,7 @@ export class OrcaRuntimeService {
state.emulator.setLastTitle(seedTitle)
this.applySeededAgentStatus(ptyId, seedTitle)
}
this.providerSnapshotPreferredPtys.delete(ptyId)
} catch {
// Hydration is best-effort. Live writes continue via the same
// writeChain that this catch-arm leaves intact.
@@ -7113,12 +7254,39 @@ export class OrcaRuntimeService {
alternateScreen?: boolean
pendingEscapeTailAnsi?: string
} | null> {
if (this.providerSnapshotPreferredPtys.has(ptyId)) {
// Why: pre-attach stream bytes only form a suffix of restored state. A
// sequenced provider snapshot safely reconciles live bytes; renderer is
// the fallback when an older provider cannot expose that boundary.
const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts)
if (providerSnapshot) {
return providerSnapshot
}
const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts)
if (rendererSnapshot) {
return rendererSnapshot
}
}
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
if (headlessSnapshot) {
return headlessSnapshot
}
return this.serializeRendererTerminalBuffer(ptyId, opts)
const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts)
if (!rendererSnapshot) {
return this.serializeProviderTerminalBuffer(ptyId, opts)
}
if (rendererSnapshot.data.length > 0) {
return rendererSnapshot
}
// Why: parked desktop panes register serializers before their xterm has
// hydrated. Treat that empty shell as provisional so retained provider
// history can restore mobile without forcing the desktop pane to mount.
const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts)
return providerSnapshot &&
(providerSnapshot.data.length > 0 || Boolean(providerSnapshot.scrollbackAnsi))
? providerSnapshot
: rendererSnapshot
}
private async serializeRendererTerminalBuffer(
@@ -7133,6 +7301,9 @@ export class OrcaRuntimeService {
source?: 'renderer'
oscLinks?: TerminalOscLinkRange[]
} | null> {
if (this.ptyController?.hasRendererSerializer?.(ptyId) === false) {
return null
}
let rendererSnapshot: {
data: string
cols: number
@@ -7163,6 +7334,49 @@ export class OrcaRuntimeService {
: null
}
private async serializeProviderTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
): Promise<PtyProviderBufferSnapshot | null> {
const liveModeTracker = new TerminalKittyKeyboardModeTracker()
let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId)
if (!liveModeTrackers) {
liveModeTrackers = new Set()
this.providerModeSnapshotScansByPtyId.set(ptyId, liveModeTrackers)
}
liveModeTrackers.add(liveModeTracker)
try {
// Why: daemon PTYs survive an app relaunch before any renderer mounts.
// Mobile still needs their retained history without navigating desktop.
const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts)
if (typeof snapshot?.alternateScreen === 'boolean') {
const modeTracker = new TerminalKittyKeyboardModeTracker()
if (snapshot.alternateScreen) {
modeTracker.scan('\x1b[?1049h')
}
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
}
this.providerModeTrackersByPtyId.set(ptyId, modeTracker)
}
if (!snapshot) {
return null
}
const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0
return this.preferTrackedLastTitle(ptyId, {
...snapshot,
seq: providerOffset + snapshot.seq
})
} catch {
return null
} finally {
liveModeTrackers.delete(liveModeTracker)
if (liveModeTrackers.size === 0) {
this.providerModeSnapshotScansByPtyId.delete(ptyId)
}
}
}
private async withVisibleSnapshotFallback(
ptyId: string,
read: RuntimeTerminalRead,
@@ -7864,6 +8078,17 @@ export class OrcaRuntimeService {
])
}
// Why: connection migration replays subscriptions; use the stale-aware lane
// so a reconnect cannot turn one mobile viewer into continuous forced fetches.
async refreshAccountsForMobileSubscriber(): Promise<void> {
const { rateLimits } = this.requireAccountServices()
await Promise.allSettled([
rateLimits.refreshIfStale(),
rateLimits.fetchInactiveClaudeAccountsOnOpen(),
rateLimits.fetchInactiveCodexAccountsOnOpen()
])
}
selectClaudeAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
return this.requireAccountServices().claudeAccounts.selectAccount(accountId)
}
@@ -8248,6 +8473,11 @@ export class OrcaRuntimeService {
this.clearWaitBlockedCheckState(ptyId)
this.recentPtyPathCandidatesById.delete(ptyId)
this.ptyOutputSequenceById.delete(ptyId)
this.providerSequenceInitializedPtys.delete(ptyId)
this.providerSequenceOffsetByPtyId.delete(ptyId)
this.providerSnapshotPreferredPtys.delete(ptyId)
this.providerModeTrackersByPtyId.delete(ptyId)
this.providerModeSnapshotScansByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.terminalSpawnCommandsByPtyId.delete(ptyId)
this.disposePtyTitleTracker(ptyId)
@@ -11292,6 +11522,7 @@ export class OrcaRuntimeService {
const previousLastOutputAt = summary.lastOutputAt
summary.liveTerminalCount += 1
summary.hasAttachedPty = true
summary.hasHostSidebarActivity = true
summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt)
summary.status = mergeWorktreeStatus(summary.status, 'active')
if (
@@ -11303,6 +11534,19 @@ export class OrcaRuntimeService {
}
const session = this.store?.getWorkspaceSession?.()
for (const worktreeId of session?.activeWorktreeIdsOnShutdown ?? []) {
const summary = this.getSummaryForRuntimeWorktreeId(
summaries,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
worktreeId
)
if (summary) {
// Why: desktop advertises deferred reattach ids as live before their
// panes mount; mobile must preserve the same startup activity view.
summary.hasHostSidebarActivity = true
}
}
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
if (tabs.length === 0) {
continue
@@ -11332,6 +11576,23 @@ export class OrcaRuntimeService {
}
}
for (const [worktreeId, tabs] of Object.entries(session?.browserTabsByWorktree ?? {})) {
if (tabs.length === 0) {
continue
}
const summary = this.getSummaryForRuntimeWorktreeId(
summaries,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
worktreeId
)
if (summary) {
// Why: desktop's sleeping predicate treats any open browser workspace
// as active, so the mobile host projection must preserve that parity.
summary.hasHostSidebarActivity = true
}
}
// Why: surface the desktop's focused worktree so mobile can scroll it into
// view and highlight it. Resolve through getSummaryForRuntimeWorktreeId so
// SSH/remote path-projected ids match the same way tabsByWorktree does.
@@ -12272,6 +12533,12 @@ export class OrcaRuntimeService {
try {
proc = gitSpawn(['clone', '--progress', '--', trimmedUrl, clonePath], {
cwd: trimmedDestination,
// Why: without the non-interactive guard, a clone that needs GitHub
// auth makes Git Credential Manager pop its "Connect to GitHub" OAuth
// window on Windows; in a network-restricted env the browser/device
// flow can never complete and git's credential retry re-pops it
// (issue #7652). Fail fast with a clear error instead.
env: nonInteractiveGitEnv(),
stdio: ['ignore', 'ignore', 'pipe']
})
} catch (err) {
@@ -17819,6 +18086,7 @@ export class OrcaRuntimeService {
command: sequencedStartupCommand
? launchOpts.command
: (agentTeamsPlan?.command ?? launchOpts.command),
launchAgent: launchOpts.launchAgent,
commandDelivery: 'provider',
startupCommandDelivery: launchOpts.startupCommandDelivery,
env,
@@ -20421,10 +20689,16 @@ export class OrcaRuntimeService {
return null
}
const sessions = sessionsResult.value
const persistedWorktreeIdByPtyId = indexPersistedPtyWorktreeBindings(
this.store?.getWorkspaceSession?.()
)
const livePtyIds = new Set(sessions.map((session) => session.id))
for (const session of sessions) {
this.adoptControllerTerminalHandle(session.id, session.terminalHandle)
// Why: workspace identity migration rekeys persisted ownership while a
// running daemon PTY keeps the worktree id minted into its session id.
const worktreeId =
persistedWorktreeIdByPtyId.get(session.id) ??
inferWorktreeIdFromPtyId(session.id) ??
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd)
if (targetWorktreeId && worktreeId !== targetWorktreeId) {
@@ -20489,6 +20763,11 @@ export class OrcaRuntimeService {
this.clearWaitBlockedCheckState(ptyId)
this.recentPtyPathCandidatesById.delete(ptyId)
this.ptyOutputSequenceById.delete(ptyId)
this.providerSequenceInitializedPtys.delete(ptyId)
this.providerSequenceOffsetByPtyId.delete(ptyId)
this.providerSnapshotPreferredPtys.delete(ptyId)
this.providerModeTrackersByPtyId.delete(ptyId)
this.providerModeSnapshotScansByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.terminalSpawnCommandsByPtyId.delete(ptyId)
this.disposePtyTitleTracker(ptyId)
@@ -26720,6 +26999,39 @@ function inferWorktreeIdFromPtyId(ptyId: string): string | null {
return parsePtySessionId(ptyId).worktreeId
}
function indexPersistedPtyWorktreeBindings(
session: WorkspaceSessionState | null | undefined
): ReadonlyMap<string, string> {
const worktreeIdByPtyId = new Map<string, string>()
const ambiguousPtyIds = new Set<string>()
const bind = (ptyId: string | null | undefined, worktreeId: string): void => {
if (!ptyId || ambiguousPtyIds.has(ptyId)) {
return
}
const existingWorktreeId = worktreeIdByPtyId.get(ptyId)
if (existingWorktreeId && existingWorktreeId !== worktreeId) {
// Why: corrupt/stale duplicate bindings must not attribute a live PTY to
// whichever workspace happened to be visited first.
worktreeIdByPtyId.delete(ptyId)
ambiguousPtyIds.add(ptyId)
return
}
worktreeIdByPtyId.set(ptyId, worktreeId)
}
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
for (const tab of tabs) {
bind(tab.ptyId, worktreeId)
bind(session?.remoteSessionIdsByTabId?.[tab.id], worktreeId)
const layout = session?.terminalLayoutsByTabId[tab.id]
for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) {
bind(ptyId, worktreeId)
}
}
}
return worktreeIdByPtyId
}
function setsEqual<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {
if (a.size !== b.size) {
return false
@@ -337,6 +337,11 @@ describe('remote runtime request connection integration', () => {
listener({ claude: null, codex: null })
}
},
refreshAccountsForMobileSubscriber: async () => {
for (const listener of accountsListeners) {
listener({ claude: null, codex: null })
}
},
onAccountsChanged: (listener: (snapshot: unknown) => void) => {
accountsListeners.add(listener)
return () => accountsListeners.delete(listener)
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { isStreamingMethod } from '../core'
import { ACCOUNT_METHODS } from './accounts'
function method(name: string) {
const found = ACCOUNT_METHODS.find((candidate) => candidate.name === name)
if (!found) {
throw new Error(`Missing method ${name}`)
}
return found
}
describe('account RPC methods', () => {
it('keeps explicit account-list refreshes on the forced refresh lane', async () => {
const snapshot = { claude: null, codex: null }
const runtime = {
refreshAccountsForMobile: vi.fn().mockResolvedValue(undefined),
getAccountsSnapshot: vi.fn(() => snapshot)
} as unknown as OrcaRuntimeService
const list = method('accounts.list')
if (isStreamingMethod(list)) {
throw new Error('accounts.list must be a request method')
}
await expect(list.handler(undefined, { runtime })).resolves.toBe(snapshot)
expect(runtime.refreshAccountsForMobile).toHaveBeenCalledOnce()
})
it('uses a stale-aware refresh when a connection replays the subscription', async () => {
const snapshot = { claude: null, codex: null }
let cleanup: (() => void) | undefined
const runtime = {
getAccountsSnapshot: vi.fn(() => snapshot),
onAccountsChanged: vi.fn(() => vi.fn()),
registerSubscriptionCleanup: vi.fn((_id: string, nextCleanup: () => void) => {
cleanup = nextCleanup
}),
refreshAccountsForMobile: vi.fn().mockResolvedValue(undefined),
refreshAccountsForMobileSubscriber: vi.fn().mockResolvedValue(undefined)
} as unknown as OrcaRuntimeService
const subscribe = method('accounts.subscribe')
if (!isStreamingMethod(subscribe)) {
throw new Error('accounts.subscribe must be a streaming method')
}
const emit = vi.fn()
const running = subscribe.handler(undefined, { runtime, connectionId: 'connection-1' }, emit)
await vi.waitFor(() => {
expect(runtime.refreshAccountsForMobileSubscriber).toHaveBeenCalledOnce()
})
expect(runtime.refreshAccountsForMobile).not.toHaveBeenCalled()
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'ready', snapshot }))
cleanup?.()
await running
})
})
+4 -4
View File
@@ -92,11 +92,11 @@ export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [
)
// Why: emit the current snapshot synchronously so the phone has
// something to render immediately, then kick a forced refresh that
// will broadcast a fresh snapshot through the listener once each
// provider fetch completes.
// something to render immediately, then refresh only stale data.
// Connection cutovers replay this subscription and must not turn the
// manual-force lane into an unbounded provider-fetch loop.
emit({ type: 'ready', subscriptionId, snapshot: runtime.getAccountsSnapshot() })
void runtime.refreshAccountsForMobile()
void runtime.refreshAccountsForMobileSubscriber()
})
}
}),
@@ -0,0 +1,187 @@
import { expect, it, vi } from 'vitest'
import { RpcDispatcher } from './dispatcher'
import type { RpcRequest } from './core'
import { TERMINAL_METHODS } from './methods/terminal'
import type { OrcaRuntimeService } from '../orca-runtime'
import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamText
} from '../../../shared/terminal-stream-protocol'
const request: RpcRequest = {
id: 'req-provider-sequence',
authToken: 'tok',
method: 'terminal.subscribe',
params: {
terminal: 'terminal-restored',
client: { id: 'phone-1', type: 'mobile' },
capabilities: { terminalBinaryStream: 1 }
}
}
it('replays post-capture output in the provider snapshot sequence domain', async () => {
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
let dataListener:
| ((data: string, meta?: { seq?: number; rawLength?: number }) => void)
| undefined
let resolveSnapshot:
| ((snapshot: { data: string; cols: number; rows: number; seq: number }) => void)
| undefined
const runtime = {
getRuntimeId: () => 'test-runtime',
registerRemoteTerminalViewSubscriber: () => () => {},
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-restored' }),
handleMobileSubscribe: vi.fn().mockResolvedValue(true),
handleMobileUnsubscribe: vi.fn(),
subscribeToTerminalData: vi.fn((_ptyId, listener) => {
dataListener = listener
return vi.fn()
}),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(
() =>
new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => {
resolveSnapshot = resolve
})
),
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>(() => {}))
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), {
connectionId: 'conn-restored',
sendBinary: (bytes) => {
binaryFrames.push(bytes)
}
})
await vi.waitFor(() => expect(resolveSnapshot).toBeDefined())
resolveSnapshot?.({ data: 'restored snapshot', cols: 80, rows: 24, seq: 900 })
// Why: provider attach anchoring makes this first new chunk continue at
// 905 instead of restarting at 5, so snapshot reconciliation keeps it.
dataListener?.('fresh', { seq: 905, rawLength: 5 })
await vi.waitFor(() =>
expect(
binaryFrames.some((bytes) => {
const frame = decodeTerminalStreamFrame(bytes)
return (
frame?.opcode === TerminalStreamOpcode.Output &&
decodeTerminalStreamText(frame.payload) === 'fresh'
)
})
).toBe(true)
)
runtime.cleanupSubscription('terminal-restored:phone-1')
await dispatchPromise
})
it('keeps provider-backed alternate-screen resizes geometry-only', async () => {
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
let resizeListener:
| ((event: {
cols: number
rows: number
displayMode: string
reason: string
seq: number
}) => void)
| undefined
const serializeTerminalBuffer = vi
.fn()
.mockResolvedValue({ data: 'restored tui', cols: 80, rows: 24 })
const runtime = {
getRuntimeId: () => 'test-runtime',
registerRemoteTerminalViewSubscriber: () => () => {},
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-tui' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer,
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
isTerminalAlternateScreen: vi.fn().mockReturnValue(true),
handleMobileSubscribe: vi.fn().mockResolvedValue(undefined),
handleMobileUnsubscribe: vi.fn(),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
subscribeToTerminalResize: vi.fn((_ptyId, listener) => {
resizeListener = listener
return vi.fn()
}),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
{
...request,
id: 'req-provider-alt-screen',
params: {
terminal: 'terminal-tui',
client: { id: 'phone-1', type: 'mobile' },
capabilities: { terminalBinaryStream: 1 }
}
},
vi.fn(),
{
connectionId: 'conn-tui-resize',
sendBinary: (bytes) => {
binaryFrames.push(bytes)
}
}
)
await vi.waitFor(() => expect(resizeListener).toBeDefined())
binaryFrames.splice(0)
resizeListener?.({
cols: 100,
rows: 30,
displayMode: 'auto',
reason: 'apply-layout',
seq: 2
})
await vi.waitFor(() =>
expect(
binaryFrames.some(
(frame) => decodeTerminalStreamFrame(frame)?.opcode === TerminalStreamOpcode.Resized
)
).toBe(true)
)
expect(serializeTerminalBuffer).toHaveBeenCalledOnce()
expect(
binaryFrames.some(
(frame) => decodeTerminalStreamFrame(frame)?.opcode === TerminalStreamOpcode.SnapshotStart
)
).toBe(false)
runtime.cleanupSubscription('terminal-tui:phone-1')
await dispatchPromise
})
+9 -7
View File
@@ -1,5 +1,7 @@
import type { SpeechModelManifest } from '../../shared/speech-types'
// Why: sizeBytes must be the exact upstream asset size — it is the UI size
// label and the download-progress denominator when content-length is missing.
export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
{
id: 'parakeet-tdt-0.6b-v3-int8',
@@ -9,7 +11,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'transducer',
provider: 'local',
language: 'multilingual',
sizeBytes: 180_000_000,
sizeBytes: 487_170_055,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2',
archiveSha256: '5793d0fd397c5778d2cf2126994d58e9d56b1be7c04d13c7a15bb1b4eafb16bf',
@@ -28,7 +30,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'transducer',
provider: 'local',
language: 'en',
sizeBytes: 170_000_000,
sizeBytes: 482_468_385,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2',
archiveSha256: '157c157bc51155e03e37d2466522a3a737dd9c72bb25f36eb18912964161e1ad',
@@ -45,7 +47,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'transducer',
provider: 'local',
language: 'zh-en',
sizeBytes: 130_000_000,
sizeBytes: 511_274_346,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2',
archiveSha256: '27ffbd9ee24ad186d99acc2f6354d7992b27bcab490812510665fa8f9389c5f8',
@@ -68,7 +70,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'paraformer',
provider: 'local',
language: 'zh-en',
sizeBytes: 115_000_000,
sizeBytes: 1_047_319_737,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-paraformer-bilingual-zh-en.tar.bz2',
archiveSha256: '5462a1fce42693deae572af1e8c4687124b12aa85fe61ff4d3168bb5280e205f',
@@ -84,7 +86,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'transducer',
provider: 'local',
language: 'en',
sizeBytes: 128_000_000,
sizeBytes: 127_887_156,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2',
archiveSha256: '9c559283e8498d3fe95913c79ca1cb454bb26281ac2b102b41306c7d752765d9',
@@ -106,7 +108,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'transducer',
provider: 'local',
language: 'zh',
sizeBytes: 74_000_000,
sizeBytes: 74_004_050,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23.tar.bz2',
archiveSha256: '2cbd71b640d9c37d3784f29367333a4577b0398b62e9deeed418170b081cba8b',
@@ -128,7 +130,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'whisper',
provider: 'local',
language: 'multilingual',
sizeBytes: 116_000_000,
sizeBytes: 116_204_861,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-tiny.tar.bz2',
archiveSha256: 'c46116994e539aa165266d96b325252728429c12535eb9d8b6a2b10f129e66b1',
@@ -0,0 +1,671 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ModelManager } from './model-manager'
const { netRequestMock } = vi.hoisted(() => ({
netRequestMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-speech-models-test'
},
net: {
request: netRequestMock
}
}))
type ModelManagerInternals = {
downloadArchiveWithRetry: (
url: string,
archivePath: string,
expectedSize: number,
modelId: string,
isAborted: () => boolean,
signal: AbortSignal
) => Promise<void>
downloadFile: (
url: string,
archivePath: string,
expectedSize: number,
modelId: string,
isAborted: () => boolean,
signal?: AbortSignal,
redirectCount?: number,
resumeOffset?: number
) => Promise<void>
}
type ScriptedResponse = {
statusCode: number
headers?: Record<string, string>
chunks?: Buffer[]
failWith?: string
}
type ScriptedResponseFactory = (sentHeaders: Record<string, string>) => ScriptedResponse
// Emulates Electron's ClientRequest/IncomingMessage closely enough for the
// download pipeline: a real Readable body so stream.pipeline semantics
// (including mid-body destroy) match production behavior.
function scriptRequest(factory: ScriptedResponseFactory): {
sentHeaders: Record<string, string>
abortMock: ReturnType<typeof vi.fn>
} {
const sentHeaders: Record<string, string> = {}
const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
const addListener = (event: string, cb: (...args: unknown[]) => void): void => {
const set = listeners.get(event) ?? new Set()
set.add(cb)
listeners.set(event, set)
}
const abortMock = vi.fn(() => request)
const request = {
setHeader: vi.fn((name: string, value: string) => {
sentHeaders[name.toLowerCase()] = value
}),
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
addListener(event, cb)
return request
}),
off: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
listeners.get(event)?.delete(cb)
return request
}),
abort: abortMock,
end: vi.fn(() => {
queueMicrotask(() => {
const spec = factory(sentHeaders)
const response = Object.assign(new Readable({ read() {} }), {
statusCode: spec.statusCode,
headers: spec.headers ?? {}
})
for (const cb of listeners.get('response') ?? []) {
cb(response)
}
setTimeout(() => {
for (const chunk of spec.chunks ?? []) {
response.push(chunk)
}
// Why: fail on a later tick so pushed chunks flush to the file
// stream first, mirroring a transfer that dies mid-body.
setTimeout(() => {
if (spec.failWith) {
response.destroy(new Error(spec.failWith))
} else {
response.push(null)
}
}, 20)
}, 20)
})
return request
})
}
netRequestMock.mockImplementationOnce(() => request)
return { sentHeaders, abortMock }
}
const PAYLOAD = Buffer.from('0123456789abcdefghij')
describe('ModelManager download resume', () => {
beforeEach(() => {
netRequestMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('resumes an interrupted download with a Range request and assembles the full file', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest(() => ({
statusCode: 200,
headers: { 'content-length': String(PAYLOAD.length) },
chunks: [PAYLOAD.subarray(0, 10)],
failWith: 'net::ERR_CONTENT_LENGTH_MISMATCH'
}))
const second = scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=10-')
return {
statusCode: 206,
headers: {
'content-length': String(PAYLOAD.length - 10),
'content-range': `bytes 10-${PAYLOAD.length - 1}/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(10)]
}
})
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(2)
expect(second.sentHeaders.range).toBe('bytes=10-')
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('uses a complete archive after a late transport failure without requesting past EOF', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest(() => ({
statusCode: 200,
headers: { 'content-length': String(PAYLOAD.length) },
chunks: [PAYLOAD],
failWith: 'net::ERR_CONNECTION_RESET'
}))
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(1)
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('requests the remaining bytes when a clean range response ends before the archive total', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const first = scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=10-')
return {
statusCode: 206,
headers: {
'content-length': '5',
'content-range': `bytes 10-14/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(10, 15)]
}
})
const second = scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=15-')
return {
statusCode: 206,
headers: {
'content-length': '5',
'content-range': `bytes 15-19/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(15)]
}
})
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
writeFileSync(archivePath, PAYLOAD.subarray(0, 10))
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(2)
expect(first.sentHeaders.range).toBe('bytes=10-')
expect(second.sentHeaders.range).toBe('bytes=15-')
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('continues through more than eight advancing range segments', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
for (let offset = 1; offset <= 9; offset += 1) {
scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe(`bytes=${offset}-`)
return {
statusCode: 206,
headers: {
'content-length': '1',
'content-range': `bytes ${offset}-${offset}/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(offset, offset + 1)]
}
})
}
scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=10-')
return {
statusCode: 206,
headers: {
'content-length': '10',
'content-range': `bytes 10-19/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(10)]
}
})
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
writeFileSync(archivePath, PAYLOAD.subarray(0, 1))
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(10)
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('bounds a server that advances by pathologically tiny segments forever', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
let bytesWritten = 0
// Advances one byte per request against a total larger than the request
// ceiling, so it makes forward progress forever without ever completing.
const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockImplementation(() => {
bytesWritten += 1
writeFileSync(archivePath, Buffer.alloc(bytesWritten))
return Promise.resolve()
})
await expect(
manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
1_000_000,
'm',
() => false,
new AbortController().signal
)
).rejects.toThrow(/too many download requests/)
// The absolute request ceiling (MAX_TOTAL_DOWNLOAD_REQUESTS) bounds it.
expect(downloadFileMock).toHaveBeenCalledTimes(4_096)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('keeps resuming a download that advances across many mid-stream drops', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
// Every attempt delivers a small slice then drops mid-stream — the
// classic "dies partway, resumes" pattern. Because each attempt makes
// forward progress, the download must complete no matter how many drops
// it takes (regression guard: a fixed failure budget used to abandon a
// still-advancing large download around attempt 8).
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
const SLICE = 2
let delivered = 0
const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockImplementation(() => {
delivered = Math.min(delivered + SLICE, PAYLOAD.length)
writeFileSync(archivePath, PAYLOAD.subarray(0, delivered))
return Promise.reject(new Error('net::ERR_CONNECTION_RESET'))
})
const download = manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
const outcome = download.then(
() => 'resolved',
(error: unknown) => (error instanceof Error ? error.message : String(error))
)
// Drive the per-attempt backoff (1s each, since progress resets the
// stall counter) well past the ten attempts this needs.
await vi.advanceTimersByTimeAsync(30_000)
await expect(outcome).resolves.toBe('resolved')
expect(downloadFileMock).toHaveBeenCalledTimes(PAYLOAD.length / SLICE)
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
vi.useRealTimers()
rmSync(dir, { recursive: true, force: true })
}
})
it('keeps the known archive total when Content-Range omits it', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=10-')
return {
statusCode: 206,
headers: {
'content-length': '5',
'content-range': 'bytes 10-14/*'
},
chunks: [PAYLOAD.subarray(10, 15)]
}
})
scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=15-')
return {
statusCode: 206,
headers: {
'content-length': '5',
'content-range': 'bytes 15-19/*'
},
chunks: [PAYLOAD.subarray(15)]
}
})
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
writeFileSync(archivePath, PAYLOAD.subarray(0, 10))
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(2)
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('rejects and discards a partial when Content-Range does not match the offset', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const mismatched = scriptRequest(() => ({
statusCode: 206,
headers: {
'content-length': '10',
'content-range': `bytes 0-9/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(0, 10)]
}))
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
writeFileSync(archivePath, PAYLOAD.subarray(0, 10))
const error = await manager
.downloadFile(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal,
0,
10
)
.catch((cause: unknown) => cause)
expect(error).toMatchObject({
message: 'Invalid Content-Range for resume at byte 10',
retryable: true
})
expect(existsSync(archivePath)).toBe(false)
expect(mismatched.abortMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('restarts from scratch when the server ignores the Range request', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest(() => ({
statusCode: 200,
headers: { 'content-length': String(PAYLOAD.length) },
chunks: [PAYLOAD.subarray(0, 10)],
failWith: 'net::ERR_CONNECTION_RESET'
}))
scriptRequest(() => ({
statusCode: 200,
headers: { 'content-length': String(PAYLOAD.length) },
chunks: [PAYLOAD]
}))
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(2)
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('does not retry non-transient failures', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest(() => ({ statusCode: 404 }))
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
await expect(
manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
).rejects.toThrow('HTTP 404')
expect(netRequestMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('aborts an HTTP error response instead of draining it after rejection', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const scripted = scriptRequest(() => ({
statusCode: 429,
headers: { 'retry-after': '3' }
}))
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const error = await manager
.downloadFile(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
PAYLOAD.length,
'm',
() => false
)
.catch((cause: unknown) => cause)
expect(error).toMatchObject({ message: 'HTTP 429', httpStatusCode: 429, retryAfterMs: 3_000 })
expect(scripted.abortMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('honors Retry-After before issuing another request', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
const rateLimitError = Object.assign(new Error('HTTP 429'), {
httpStatusCode: 429,
retryAfterMs: 3_000
})
const downloadFileMock = vi
.spyOn(manager, 'downloadFile')
.mockRejectedValueOnce(rateLimitError)
.mockImplementationOnce(() => {
writeFileSync(archivePath, PAYLOAD)
return Promise.resolve()
})
const download = manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
await vi.advanceTimersByTimeAsync(2_999)
expect(downloadFileMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
await expect(download).resolves.toBeUndefined()
expect(downloadFileMock).toHaveBeenCalledTimes(2)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('does not retry before an excessively long Retry-After window', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const rateLimitError = Object.assign(new Error('HTTP 429'), {
httpStatusCode: 429,
retryAfterMs: 300_000
})
const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockRejectedValue(rateLimitError)
await expect(
manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
).rejects.toThrow('server requested retry after 300 seconds')
expect(downloadFileMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('gives up after repeated zero-progress failures with a diagnosable error', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const downloadFileMock = vi
.spyOn(manager, 'downloadFile')
.mockRejectedValue(new Error('net::ERR_CONNECTION_RESET'))
const download = manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
const outcome = download.then(
() => 'resolved',
(error: unknown) => (error instanceof Error ? error.message : String(error))
)
// Let the first rejection schedule its backoff before advancing timers;
// this avoids racing fake time against stream/file I/O under full-suite load.
await vi.advanceTimersByTimeAsync(0)
expect(downloadFileMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(999)
expect(downloadFileMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(downloadFileMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1_999)
expect(downloadFileMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(downloadFileMock).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(3_999)
expect(downloadFileMock).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(1)
await expect(outcome).resolves.toMatch(
/Model download interrupted at 0% \(0 of 20 bytes\) after 4 attempts: .*net::ERR_CONNECTION_RESET/
)
expect(downloadFileMock).toHaveBeenCalledTimes(4)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('stops retrying once the download is aborted', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest(() => ({
statusCode: 200,
headers: { 'content-length': String(PAYLOAD.length) },
failWith: 'net::ERR_CONNECTION_RESET'
}))
const controller = new AbortController()
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const download = manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
PAYLOAD.length,
'm',
() => false,
controller.signal
)
const outcome = download.then(
() => 'resolved',
(error: unknown) => (error instanceof Error ? error.message : String(error))
)
// Abort after the transfer failure enters backoff; the next attempt must
// settle as Aborted without issuing another request.
setTimeout(() => controller.abort(), 100)
const message = await outcome
expect(message).toBe('Aborted')
expect(netRequestMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+323 -16
View File
@@ -1,7 +1,14 @@
/* eslint-disable max-lines -- Why: model download, checksum, extraction, and cleanup share one state machine so progress/error transitions stay coupled. */
import { app, net } from 'electron'
import { join, resolve, relative } from 'node:path'
import { existsSync, mkdirSync, createWriteStream, createReadStream, rmSync } from 'node:fs'
import {
existsSync,
mkdirSync,
createWriteStream,
createReadStream,
rmSync,
statSync
} from 'node:fs'
import { readdir, rm } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { pipeline } from 'node:stream/promises'
@@ -28,11 +35,129 @@ type ProgressCallback = (modelId: string, progress: number) => void
type DownloadIncomingMessage = Electron.IncomingMessage &
NodeJS.ReadableStream & {
headers: Record<string, string | string[] | undefined>
resume: () => void
destroy?: () => void
}
type HttpStatusError = Error & {
httpStatusCode?: number
retryAfterMs?: number
retryable?: boolean
}
type DownloadTotals = { totalBytes: number }
type ContentRange = { start: number; end: number; totalBytes?: number }
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000
// Why: flaky networks and scanning proxies commonly kill long CDN transfers
// near the end (reported as "stuck at 90%, then fails"); resuming with a Range
// request lets those connections finish instead of failing the same way on
// every full restart.
const DOWNLOAD_RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
// Why: abandon a download only after this many CONSECUTIVE attempts make no
// forward progress. A resume that keeps advancing (even across many mid-stream
// drops) is never given up on — that is the whole point of resumable downloads
// for large models over flaky networks, and a fixed all-time failure budget
// would wrongly kill a steadily-progressing large download.
const MAX_NO_PROGRESS_ATTEMPTS = DOWNLOAD_RETRY_DELAYS_MS.length + 1
// Why: an absolute backstop so a server handing back pathologically tiny (or
// zero-net) segments cannot loop forever; sized to cover the largest (~1GB)
// model even if a proxy caps each range response near ~256KB, far below any
// real CDN chunk.
const MAX_TOTAL_DOWNLOAD_REQUESTS = 4_096
// Why: a longer server window should be surfaced for a later manual retry,
// rather than leaving the settings download looking stuck for minutes.
const MAX_RETRY_AFTER_MS = 120_000
const RETRYABLE_NET_ERROR =
/net::ERR_(CONTENT_LENGTH_MISMATCH|INCOMPLETE_CHUNKED_ENCODING|CONNECTION_(RESET|CLOSED|ABORTED|REFUSED|TIMED_OUT)|EMPTY_RESPONSE|NETWORK_CHANGED|TIMED_OUT|INTERNET_DISCONNECTED|ADDRESS_UNREACHABLE|NAME_NOT_RESOLVED|SOCKET_NOT_CONNECTED|HTTP2_PROTOCOL_ERROR|QUIC_PROTOCOL_ERROR)\b/
const RETRYABLE_HTTP_STATUSES = new Set([408, 416, 425, 429, 500, 502, 503, 504])
function isRetryableDownloadError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
const downloadError = error as HttpStatusError
if (downloadError.retryable === true) {
return true
}
const statusCode = downloadError.httpStatusCode
if (statusCode !== undefined) {
return RETRYABLE_HTTP_STATUSES.has(statusCode)
}
return (
RETRYABLE_NET_ERROR.test(error.message) || error.message.includes('without network activity')
)
}
function getHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value
}
function parseContentRange(value: string | string[] | undefined): ContentRange | null {
const match = getHeaderValue(value)
?.trim()
.match(/^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i)
if (!match) {
return null
}
const start = Number.parseInt(match[1], 10)
const end = Number.parseInt(match[2], 10)
const totalBytes = match[3] === '*' ? undefined : Number.parseInt(match[3], 10)
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
end < start ||
(totalBytes !== undefined && (!Number.isSafeInteger(totalBytes) || totalBytes <= end))
) {
return null
}
return { start, end, totalBytes }
}
function parseRetryAfterMs(value: string | string[] | undefined): number | undefined {
const header = getHeaderValue(value)?.trim()
if (!header) {
return undefined
}
if (/^\d+$/.test(header)) {
const seconds = Number.parseInt(header, 10)
const delayMs = seconds * 1_000
return Number.isSafeInteger(delayMs) ? delayMs : undefined
}
const retryAt = Date.parse(header)
return Number.isNaN(retryAt) ? undefined : Math.max(0, retryAt - Date.now())
}
function describeInterruptedDownload(
cause: unknown,
receivedBytes: number,
totalBytes: number,
attempts: number
): Error {
const causeMessage = cause instanceof Error ? cause.message : String(cause)
const received =
totalBytes > 0
? `${Math.min(99, Math.floor((receivedBytes / totalBytes) * 100))}% (${receivedBytes} of ${totalBytes} bytes)`
: `${receivedBytes} bytes`
return new Error(
`Model download interrupted at ${received} after ${attempts} attempts: ${causeMessage}`
)
}
function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve()
return
}
const onAbort = (): void => {
clearTimeout(timer)
resolve()
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal.addEventListener('abort', onAbort, { once: true })
})
}
export class ModelManager {
private modelsDir: string
@@ -177,6 +302,15 @@ export class ModelManager {
this.updateState(modelId, 'downloading', 0)
const archivePath = join(this.modelsDir, `${modelId}.tar.bz2`)
// Why: resume appends to the partial archive from this download run only;
// a leftover file from a crashed run would corrupt the resumed archive.
try {
if (existsSync(archivePath)) {
rmSync(archivePath)
}
} catch {
// best-effort; the first (non-resumed) attempt truncates on write
}
let aborted = false
const abortController = new AbortController()
@@ -191,7 +325,7 @@ export class ModelManager {
this.activeDownloads.set(modelId, handle)
try {
await this.downloadFile(
await this.downloadArchiveWithRetry(
manifest.downloadUrl,
archivePath,
manifest.sizeBytes,
@@ -239,6 +373,7 @@ export class ModelManager {
this.updateState(modelId, 'ready')
} catch (err) {
if (!aborted) {
console.error('[speech] Model download failed:', modelId, err)
this.updateState(modelId, 'error', undefined, String(err))
}
this.cleanup(modelId, archivePath)
@@ -309,6 +444,120 @@ export class ModelManager {
}
}
private getPartialArchiveBytes(archivePath: string): number {
try {
return statSync(archivePath).size
} catch {
return 0
}
}
private async downloadArchiveWithRetry(
url: string,
archivePath: string,
expectedSize: number,
modelId: string,
isAborted: () => boolean,
signal: AbortSignal
): Promise<void> {
let requestCount = 0
let noProgressStreak = 0
const totals: DownloadTotals = { totalBytes: expectedSize }
for (;;) {
requestCount += 1
const offset = this.getPartialArchiveBytes(archivePath)
// Why: the transport can fail after the final byte reached disk; the
// caller's SHA-256 check is the authoritative completion test.
if (offset === totals.totalBytes) {
return
}
// Why: absolute backstop against a server that never lets the download
// finish; the stall and abort paths handle the common cases first.
if (requestCount > MAX_TOTAL_DOWNLOAD_REQUESTS) {
throw describeInterruptedDownload(
new Error('too many download requests'),
offset,
totals.totalBytes,
requestCount - 1
)
}
try {
// Why: each attempt restarts from the canonical URL rather than the
// last redirect target, because CDN redirect URLs are signed and expire.
await this.downloadFile(
url,
archivePath,
expectedSize,
modelId,
isAborted,
signal,
0,
offset,
totals
)
const receivedBytes = this.getPartialArchiveBytes(archivePath)
if (receivedBytes === totals.totalBytes) {
return
}
if (receivedBytes > totals.totalBytes) {
throw new Error(
`Model download exceeded its expected size (${receivedBytes} of ${totals.totalBytes} bytes)`
)
}
const incompleteResponse = new Error(
`Model download response ended at ${receivedBytes} of ${totals.totalBytes} bytes`
)
if (receivedBytes > offset) {
// Why: some proxies return a valid but bounded range segment; request
// the next segment immediately instead of failing checksum or waiting.
// Forward progress resets the stall counter; the total-request ceiling
// at the loop top bounds a pathological tiny-segment server.
noProgressStreak = 0
continue
}
const retryableIncompleteResponse = incompleteResponse as HttpStatusError
retryableIncompleteResponse.retryable = true
throw retryableIncompleteResponse
} catch (err) {
if (isAborted() || signal.aborted) {
throw err
}
const receivedBytes = this.getPartialArchiveBytes(archivePath)
if (receivedBytes === totals.totalBytes) {
return
}
noProgressStreak = receivedBytes > offset ? 0 : noProgressStreak + 1
if (!isRetryableDownloadError(err)) {
throw err
}
// Why: give up only on a genuine stall (repeated no forward progress);
// a download that keeps advancing across drops is allowed to continue.
if (noProgressStreak >= MAX_NO_PROGRESS_ATTEMPTS) {
throw describeInterruptedDownload(err, receivedBytes, totals.totalBytes, requestCount)
}
const retryAfterMs = (err as HttpStatusError).retryAfterMs
if (retryAfterMs !== undefined && retryAfterMs > MAX_RETRY_AFTER_MS) {
const statusCode = (err as HttpStatusError).httpStatusCode
throw new Error(
`HTTP ${statusCode}; server requested retry after ${Math.ceil(retryAfterMs / 1_000)} seconds`
)
}
console.warn(
`[speech] Model download attempt ${requestCount} failed, retrying:`,
modelId,
err
)
await sleepUnlessAborted(
retryAfterMs ??
DOWNLOAD_RETRY_DELAYS_MS[
Math.min(Math.max(0, noProgressStreak - 1), DOWNLOAD_RETRY_DELAYS_MS.length - 1)
],
signal
)
}
}
}
private downloadFile(
url: string,
dest: string,
@@ -316,7 +565,9 @@ export class ModelManager {
modelId: string,
isAborted: () => boolean,
signal?: AbortSignal,
redirectCount = 0
redirectCount = 0,
resumeOffset = 0,
totals?: DownloadTotals
): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
@@ -425,28 +676,81 @@ export class ModelManager {
modelId,
isAborted,
signal,
redirectCount + 1
redirectCount + 1,
resumeOffset,
totals
)
.then(resolveOnce)
.catch(rejectOnce)
}
const onResponse = (incoming: Electron.IncomingMessage): void => {
const response = incoming as DownloadIncomingMessage
if (response.statusCode !== 200) {
response.resume()
rejectOnce(new Error(`HTTP ${response.statusCode}`))
const contentLength = response.headers['content-length']
const headerLength = Number.parseInt(getHeaderValue(contentLength) || '0', 10)
const parsedLength =
Number.isSafeInteger(headerLength) && headerLength > 0 ? headerLength : 0
const contentRange = parseContentRange(response.headers['content-range'])
const resumed =
resumeOffset > 0 &&
response.statusCode === 206 &&
contentRange?.start === resumeOffset &&
(parsedLength <= 0 || parsedLength === contentRange.end - contentRange.start + 1)
if (resumeOffset > 0 && response.statusCode === 206 && !resumed) {
// Why: appending an unverified range can silently corrupt the archive;
// discard it so the canonical URL is retried from byte zero.
try {
rmSync(dest)
} catch {
// best-effort
}
const activeRequest = request
const rangeError: HttpStatusError = new Error(
`Invalid Content-Range for resume at byte ${resumeOffset}`
)
rangeError.retryable = true
rejectOnce(rangeError)
activeRequest?.abort()
return
}
const contentLength = response.headers['content-length']
const totalSize =
Number.parseInt(
Array.isArray(contentLength) ? contentLength[0] : contentLength || '0',
10
) || expectedSize
if (response.statusCode !== 200 && !resumed) {
if (response.statusCode === 416) {
// Why: the server rejected our resume offset; drop the partial so
// the retry loop can restart this download from scratch.
try {
rmSync(dest)
} catch {
// best-effort
}
}
const activeRequest = request
const statusError: HttpStatusError = new Error(`HTTP ${response.statusCode}`)
statusError.httpStatusCode = response.statusCode
statusError.retryAfterMs = parseRetryAfterMs(response.headers['retry-after'])
rejectOnce(statusError)
// Why: retrying must not leave a prior error body draining without
// cancellation or idle-timeout ownership.
activeRequest?.abort()
return
}
// Why: a 200 despite our Range request means the server restarted the
// transfer from byte zero, so the partial file must be overwritten.
const progressBase = resumed ? resumeOffset : 0
// Why: Content-Length on a 206 describes only this segment; when
// Content-Range uses '*', retain the known complete archive size.
const totalSize = resumed
? (contentRange?.totalBytes ?? totals?.totalBytes ?? expectedSize)
: parsedLength > 0
? parsedLength
: expectedSize
if (totals) {
totals.totalBytes = totalSize
}
let downloaded = 0
const fileStream = createWriteStream(dest)
const fileStream = createWriteStream(dest, { flags: resumed ? 'a' : 'w' })
const cleanupResponseProgressListener = (): void => {
response.off('data', onResponseData)
@@ -460,7 +764,7 @@ export class ModelManager {
return
}
downloaded += chunk.length
const progress = Math.min(0.9, downloaded / totalSize)
const progress = Math.min(0.9, (progressBase + downloaded) / totalSize)
this.updateState(modelId, 'downloading', progress)
}
@@ -481,6 +785,9 @@ export class ModelManager {
}
request = net.request({ method: 'GET', url: parsedUrl.toString() })
if (resumeOffset > 0) {
request.setHeader('Range', `bytes=${resumeOffset}-`)
}
// Why: Electron's net stack honors app proxy settings, unlike Node's
// https client, but it does not expose request.setTimeout().
+1 -17
View File
@@ -1,17 +1 @@
export function addWslEnvKeys(
env: Record<string, string | undefined>,
keys: readonly string[]
): void {
const existing = env.WSLENV ?? process.env.WSLENV ?? ''
const tokens = existing.split(':').filter(Boolean)
const tokenNames = new Set(tokens.map((token) => token.split('/')[0]))
for (const key of keys) {
if (!tokenNames.has(key)) {
tokens.push(key)
tokenNames.add(key)
}
}
env.WSLENV = tokens.join(':')
}
export { addWslEnvKeys } from '../shared/wsl-env'
+17 -4
View File
@@ -1020,12 +1020,21 @@ export type PreloadApi = {
getDefaultCreateProjectParent: () => Promise<string>
onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void
getGitUsername: (args: { repoId: string }) => Promise<string>
getBaseRefDefault: (args: { repoId: string }) => Promise<BaseRefDefaultResult>
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise<string[]>
getBaseRefDefault: (args: {
repoId: string
hostId?: ExecutionHostId
}) => Promise<BaseRefDefaultResult>
searchBaseRefs: (args: {
repoId: string
query: string
limit?: number
hostId?: ExecutionHostId
}) => Promise<string[]>
searchBaseRefDetails: (args: {
repoId: string
query: string
limit?: number
hostId?: ExecutionHostId
}) => Promise<BaseRefSearchResult[]>
onChanged: (callback: () => void) => () => void
}
@@ -2242,7 +2251,7 @@ export type PreloadApi = {
worktreePath: string
command: string
}) => Promise<WorktreeSetupLaunch>
readIssueCommand: (args: { repoId: string }) => Promise<{
readIssueCommand: (args: { repoId: string; hostId?: ExecutionHostId }) => Promise<{
status?: 'ok' | 'error'
localContent: string | null
sharedContent: string | null
@@ -2250,7 +2259,11 @@ export type PreloadApi = {
localFilePath: string
source: 'local' | 'shared' | 'none'
}>
writeIssueCommand: (args: { repoId: string; content: string }) => Promise<void>
writeIssueCommand: (args: {
repoId: string
content: string
hostId?: ExecutionHostId
}) => Promise<void>
}
ephemeralVm: {
listRecipes: (args: { repoId: string }) => Promise<{
+17 -6
View File
@@ -598,16 +598,23 @@ const api = {
getGitUsername: (args: { repoId: string }): Promise<string> =>
ipcRenderer.invoke('repos:getGitUsername', args),
getBaseRefDefault: (args: { repoId: string }): Promise<BaseRefDefaultResult> =>
ipcRenderer.invoke('repos:getBaseRefDefault', args),
getBaseRefDefault: (args: {
repoId: string
hostId?: ExecutionHostId
}): Promise<BaseRefDefaultResult> => ipcRenderer.invoke('repos:getBaseRefDefault', args),
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }): Promise<string[]> =>
ipcRenderer.invoke('repos:searchBaseRefs', args),
searchBaseRefs: (args: {
repoId: string
query: string
limit?: number
hostId?: ExecutionHostId
}): Promise<string[]> => ipcRenderer.invoke('repos:searchBaseRefs', args),
searchBaseRefDetails: (args: {
repoId: string
query: string
limit?: number
hostId?: ExecutionHostId
}): Promise<BaseRefSearchResult[]> => ipcRenderer.invoke('repos:searchBaseRefDetails', args),
onChanged: (callback: () => void): (() => void) => {
@@ -2639,6 +2646,7 @@ const api = {
readIssueCommand: (args: {
repoId: string
hostId?: ExecutionHostId
}): Promise<{
status?: 'ok' | 'error'
localContent: string | null
@@ -2648,8 +2656,11 @@ const api = {
source: 'local' | 'shared' | 'none'
}> => ipcRenderer.invoke('hooks:readIssueCommand', args),
writeIssueCommand: (args: { repoId: string; content: string }): Promise<void> =>
ipcRenderer.invoke('hooks:writeIssueCommand', args)
writeIssueCommand: (args: {
repoId: string
content: string
hostId?: ExecutionHostId
}): Promise<void> => ipcRenderer.invoke('hooks:writeIssueCommand', args)
},
ephemeralVm: {
+87 -1
View File
@@ -2,6 +2,7 @@ import { exec, spawn } from 'node:child_process'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ChildProcess from 'node:child_process'
import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard'
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof ChildProcess>()
@@ -52,7 +53,11 @@ describe('AgentExecHandler', () => {
})
expect(spawnMock).toHaveBeenCalledWith('agent', ['--flag', '42'], {
cwd: '/repo',
env: process.env,
env: expect.objectContaining({
...process.env,
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never'
}),
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
@@ -97,6 +102,87 @@ describe('AgentExecHandler', () => {
})
})
it('consumes an unattended marker and applies the full Git guard on the relay host', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: '/bin/bash',
args: ['-lc', 'git fetch'],
cwd: '/repo',
timeoutMs: 5_000,
env: { [TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard' }
},
requestContext()
)
child.emit('close', 0)
await expect(pending).resolves.toMatchObject({ exitCode: 0 })
const env = spawnMock.mock.calls[0]?.[2]?.env as Record<string, string>
expect(env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBeUndefined()
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(Object.values(env)).toContain('credential.interactive')
expect(Object.values(env)).toContain('credential.guiPrompt')
})
it('guards wrapped agents after atomically replacing inherited indexed config', async () => {
const keys = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_KEY_0',
'GIT_CONFIG_VALUE_0',
'GIT_CONFIG_KEY_1',
'GIT_CONFIG_VALUE_1'
] as const
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
process.env.GIT_CONFIG_COUNT = '2'
process.env.GIT_CONFIG_KEY_0 = 'base.one'
process.env.GIT_CONFIG_VALUE_0 = 'one'
process.env.GIT_CONFIG_KEY_1 = 'base.two'
process.env.GIT_CONFIG_VALUE_1 = 'two'
try {
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: 'npx',
args: ['codex', 'exec'],
cwd: '/repo',
timeoutMs: 5_000,
env: {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
}
},
requestContext()
)
child.emit('close', 0)
await expect(pending).resolves.toMatchObject({ exitCode: 0 })
const env = spawnMock.mock.calls[0]?.[2]?.env as Record<string, string>
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GIT_CONFIG_COUNT).toBe('3')
expect(env.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(env.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(env.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
expect(Object.values(env)).not.toContain('base.two')
} finally {
for (const key of keys) {
if (saved[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = saved[key]
}
}
}
})
it('cancels the in-flight command for the requested cwd', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
+12 -1
View File
@@ -2,6 +2,8 @@ import { exec, spawn, type ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { delimiter, join } from 'node:path'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard'
import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env'
const DEFAULT_TIMEOUT_MS = 60_000
const MAX_TIMEOUT_MS = 5 * 60 * 1000
@@ -168,7 +170,16 @@ export class AgentExecHandler {
params.env && typeof params.env === 'object' && !Array.isArray(params.env)
? (params.env as Record<string, string>)
: null
const spawnEnv = extraEnv ? { ...process.env, ...extraEnv } : process.env
const spawnEnv = mergeGitConfigEnvProtocol(process.env, extraEnv ?? undefined) as Record<
string,
string
>
// Why: this RPC has no interactive terminal, regardless of which wrapper
// launches the agent or hook command.
applyTerminalGitCredentialPromptGuard(spawnEnv, {
isUnattended: true,
platform: process.platform
})
return new Promise<ExecResult>((resolve) => {
let child
+3 -9
View File
@@ -59,7 +59,7 @@ import {
resolveEffectiveGitUpstream
} from '../shared/git-effective-upstream'
import { loadGitHistoryFromExecutor } from '../shared/git-history'
import { buildRelayGitEnv } from './relay-command-env'
import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from './relay-command-env'
import {
removeSafeUntrackedDiscardTarget,
removeSafeUntrackedDiscardTargets
@@ -307,16 +307,10 @@ export class GitHandler {
timeout?: number
}
): Promise<{ stdout: string; stderr: string }> {
const env = buildRelayGitEnv()
const env = opts?.nonInteractive ? buildRelayUnattendedGitEnv() : buildRelayGitEnv()
if (opts?.disableOptionalLocks) {
env.GIT_OPTIONAL_LOCKS = '0'
}
if (opts?.nonInteractive) {
env.GIT_TERMINAL_PROMPT = '0'
env.GIT_ASKPASS = ''
env.SSH_ASKPASS = ''
env.GIT_SSH_COMMAND ??= 'ssh -o BatchMode=yes'
}
const execOptions = {
cwd: expandTilde(cwd),
env,
@@ -1182,7 +1176,7 @@ export class GitHandler {
return await new Promise((resolve, reject) => {
const child = spawn('git', args, {
cwd: expandTilde(cwd),
env: buildRelayGitEnv(),
env: buildRelayUnattendedGitEnv(),
stdio: ['ignore', 'pipe', 'pipe']
})
let stdout = ''
+152
View File
@@ -174,6 +174,97 @@ describe('PtyHandler', () => {
expect(handler.activePtyCount).toBe(1)
})
it('guards SSH agent terminals after merging the relay inherited Git config', async () => {
const gitConfigKeys = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_KEY_0',
'GIT_CONFIG_VALUE_0',
'GIT_CONFIG_KEY_1',
'GIT_CONFIG_VALUE_1',
'GIT_CONFIG_KEY_2',
'GIT_CONFIG_VALUE_2'
] as const
const saved = Object.fromEntries(gitConfigKeys.map((key) => [key, process.env[key]]))
process.env.GIT_CONFIG_COUNT = '3'
process.env.GIT_CONFIG_KEY_0 = 'core.quotePath'
process.env.GIT_CONFIG_VALUE_0 = 'false'
process.env.GIT_CONFIG_KEY_1 = 'base.one'
process.env.GIT_CONFIG_VALUE_1 = 'one'
process.env.GIT_CONFIG_KEY_2 = 'base.two'
process.env.GIT_CONFIG_VALUE_2 = 'two'
try {
await dispatcher.callRequest('pty.spawn', {
command: 'claude',
env: {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
}
})
const spawnEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(spawnEnv.GCM_INTERACTIVE).toBe('never')
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('3')
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(spawnEnv.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(spawnEnv.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
expect(spawnEnv.GIT_CONFIG_KEY_3).toBeUndefined()
} finally {
for (const key of gitConfigKeys) {
if (saved[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = saved[key]
}
}
}
})
it('guards a trusted SSH agent when its command uses a custom wrapper', async () => {
await dispatcher.callRequest('pty.spawn', {
command: 'cd /repo && custom-agent-wrapper',
launchAgent: 'claude'
})
const spawnEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(spawnEnv.GCM_INTERACTIVE).toBe('never')
expect(Object.values(spawnEnv)).toContain('credential.interactive')
expect(Object.values(spawnEnv)).toContain('credential.guiPrompt')
})
it('leaves an ordinary Windows SSH user terminal unchanged', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
await dispatcher.callRequest('pty.spawn', {
env: {
GIT_TERMINAL_PROMPT: '1',
GCM_INTERACTIVE: 'auto',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.quotePath',
GIT_CONFIG_VALUE_0: 'false'
}
})
const userEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(userEnv.GIT_TERMINAL_PROMPT).toBe('1')
expect(userEnv.GCM_INTERACTIVE).toBe('auto')
expect(userEnv.GIT_CONFIG_COUNT).toBe('1')
expect(userEnv.GIT_CONFIG_KEY_0).toBe('core.quotePath')
expect(userEnv.GIT_CONFIG_KEY_1).toBeUndefined()
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
expect(JSON.parse(state)[0]?.gitCredentialPromptGuarded).toBe(false)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('uses an explicit shell override and falls back to the default shell otherwise', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
@@ -1555,6 +1646,67 @@ describe('PtyHandler', () => {
expect(callArgs.env.TERM_PROGRAM).toBe('Orca')
})
it('revive preserves the credential guard chosen for an SSH agent terminal', async () => {
await dispatcher.callRequest('pty.spawn', {
command: 'claude'
})
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
expect(JSON.parse(state)[0]?.gitCredentialPromptGuarded).toBe(true)
handler.dispose()
mockPtySpawn.mockClear()
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const revivedEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(revivedEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(revivedEnv.GCM_INTERACTIVE).toBe('never')
expect(Object.values(revivedEnv)).toContain('credential.interactive')
expect(Object.values(revivedEnv)).toContain('credential.guiPrompt')
})
it('revive treats legacy relay state as an ordinary unguarded terminal', async () => {
const savedTerminalPrompt = process.env.GIT_TERMINAL_PROMPT
const savedGcmInteractive = process.env.GCM_INTERACTIVE
delete process.env.GIT_TERMINAL_PROMPT
delete process.env.GCM_INTERACTIVE
const state = JSON.stringify([
{
id: 'pty-legacy',
pid: process.pid,
cols: 80,
rows: 24,
cwd: process.cwd()
}
])
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
const revivedEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(revivedEnv.GIT_TERMINAL_PROMPT).toBeUndefined()
expect(revivedEnv.GCM_INTERACTIVE).toBeUndefined()
} finally {
killSpy.mockRestore()
if (savedTerminalPrompt === undefined) {
delete process.env.GIT_TERMINAL_PROMPT
} else {
process.env.GIT_TERMINAL_PROMPT = savedTerminalPrompt
}
if (savedGcmInteractive === undefined) {
delete process.env.GCM_INTERACTIVE
} else {
process.env.GCM_INTERACTIVE = savedGcmInteractive
}
}
})
it('normalizes an explicit empty TERM and preserves sanitized env deletions on revive', async () => {
await dispatcher.callRequest('pty.spawn', {
env: { TERM: '' },
+39 -11
View File
@@ -24,6 +24,12 @@ import {
scanForShellReady,
type ShellReadyScanState
} from '../main/shell-ready-marker-scanner'
import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard'
import {
gitCredentialPromptGuardEnv,
mergeGitConfigEnvProtocol
} from '../shared/git-credential-prompt-env'
import { isTuiAgent } from '../shared/tui-agent-config'
// Why: node-pty is a native addon that may not be installed on the remote.
// Dynamic import keeps the require() lazy so loadPty() returns null gracefully
@@ -70,6 +76,7 @@ type ManagedPty = {
terminalHandle?: string
explicitTerm?: string
envToDelete: string[]
gitCredentialPromptGuarded: boolean
startupCommand?: ManagedStartupCommand
}
@@ -196,6 +203,8 @@ type SerializedPtyEntry = {
terminalHandle?: string
explicitTerm?: string
envToDelete?: string[]
/** Optional for state serialized by relays predating the credential guard. */
gitCredentialPromptGuarded?: boolean
}
function sanitizeEnvToDelete(value: unknown): string[] {
@@ -310,16 +319,18 @@ export class PtyHandler {
ctx: { id: string; paneKey?: string; shell: string; command?: string },
envToDelete: readonly string[] = []
): Record<string, string> {
const baseEnv = {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
TERM_PROGRAM_VERSION:
rendererEnv?.ORCA_APP_VERSION || process.env.ORCA_APP_VERSION || '0.0.0-dev',
FORCE_HYPERLINK: '1',
...rendererEnv
} as Record<string, string>
const baseEnv = mergeGitConfigEnvProtocol(
{
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
TERM_PROGRAM_VERSION:
rendererEnv?.ORCA_APP_VERSION || process.env.ORCA_APP_VERSION || '0.0.0-dev',
FORCE_HYPERLINK: '1'
},
rendererEnv
) as Record<string, string>
const augmented: Record<string, string> = {}
for (const augmenter of this.envAugmenters) {
try {
@@ -330,7 +341,7 @@ export class PtyHandler {
)
}
}
const result = { ...baseEnv, ...augmented }
const result = mergeGitConfigEnvProtocol(baseEnv, augmented) as Record<string, string>
// Why: match local/daemon precedence so relay defaults and augmenters
// cannot resurrect attribution or identity values explicitly removed.
for (const key of envToDelete) {
@@ -648,6 +659,14 @@ export class PtyHandler {
const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }, envToDelete)
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command)
// Why: SSH PTYs bypass main's host-env builder. Apply the policy only
// after the relay merges its authoritative process environment so indexed
// Git config and remote Windows/WSL behavior remain intact.
const gitCredentialPromptGuarded = applyTerminalGitCredentialPromptGuard(spawnEnv, {
launchCommand: launchCommandHint,
isUnattended: isTuiAgent(params.launchAgent),
platform: process.platform
})
const shouldEmitShellReadyMarker =
launchCommandHint !== undefined &&
shouldUseShellReadyStartupDelivery({
@@ -700,6 +719,7 @@ export class PtyHandler {
worktreeId,
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
...(terminalHandle ? { terminalHandle } : {}),
...(shouldProviderDeliverCommand
? {
@@ -1002,6 +1022,7 @@ export class PtyHandler {
worktreeId: managed.worktreeId,
...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}),
envToDelete: managed.envToDelete,
gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded,
...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {})
})
}
@@ -1070,6 +1091,12 @@ export class PtyHandler {
},
envToDelete
)
// Why: revive lacks the original launch command, so preserve the guard
// decision made at fresh spawn. Legacy state remains an ordinary shell.
const gitCredentialPromptGuarded = entry.gitCredentialPromptGuarded === true
if (gitCredentialPromptGuarded) {
Object.assign(spawnEnv, gitCredentialPromptGuardEnv(spawnEnv, process.platform))
}
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv)
const term = ptyMod.spawn(shell, shellLaunch.args, {
// Why: revive must preserve the same terminal identity as fresh spawn.
@@ -1092,6 +1119,7 @@ export class PtyHandler {
worktreeId: entry.worktreeId,
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {})
})
+31 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { homedir } from 'node:os'
import { buildRelayCommandEnv, buildRelayGitEnv } from './relay-command-env'
import {
buildRelayCommandEnv,
buildRelayGitEnv,
buildRelayUnattendedGitEnv
} from './relay-command-env'
// homedir() is the fallback when the relay env carries no HOME; mock it so the
// fallback path is deterministic and the "no resolvable home" branch is reachable.
@@ -245,3 +249,29 @@ describe('buildRelayGitEnv', () => {
expect(env.PATH?.split(':')).toEqual(expect.arrayContaining(['/usr/bin', '/usr/local/bin']))
})
})
describe('buildRelayUnattendedGitEnv', () => {
it('disables credential UI while preserving relay PATH, locale, and caller askpass', () => {
const env = buildRelayUnattendedGitEnv(
{
HOME: '/home/me',
PATH: '/custom/bin',
LC_ALL: 'de_DE.UTF-8',
GIT_ASKPASS: '/opt/noninteractive-credential-feeder'
},
'linux'
)
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(env.GIT_ASKPASS).toBe('/opt/noninteractive-credential-feeder')
expect(env.GIT_CONFIG_COUNT).toBe('2')
expect(env.GIT_CONFIG_KEY_0).toBe('credential.interactive')
expect(env.GIT_CONFIG_VALUE_0).toBe('false')
expect(env.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt')
expect(env.GIT_CONFIG_VALUE_1).toBe('false')
expect(env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
expect(env.LC_ALL).toBe('en_US.UTF-8')
expect(env.PATH?.split(':')).toEqual(expect.arrayContaining(['/custom/bin', '/usr/bin']))
})
})
+13
View File
@@ -1,5 +1,6 @@
import { homedir } from 'node:os'
import { posix, win32 } from 'node:path'
import { gitCredentialPromptGuardEnv } from '../shared/git-credential-prompt-env'
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../shared/git-output-locale'
const POSIX_RELAY_PATH_FALLBACKS = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin']
@@ -168,3 +169,15 @@ export function buildRelayGitEnv(
): NodeJS.ProcessEnv {
return { ...buildRelayCommandEnv(baseEnv, platform), ...UNTRANSLATED_GIT_OUTPUT_ENV }
}
/** Env for unattended Git RPCs, which have no terminal that can answer auth UI. */
export function buildRelayUnattendedGitEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
// Why: SSH-host GCM can open its own OAuth window even though the clone's
// stdin is ignored, leaving the relay request hung with no way to answer it.
const env = gitCredentialPromptGuardEnv(buildRelayGitEnv(baseEnv, platform), platform)
env.GIT_SSH_COMMAND ??= 'ssh -o BatchMode=yes'
return env
}
@@ -181,6 +181,10 @@ import { shouldPollChromiumErrorPage } from './chromium-error-page-polling'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import { translate } from '@/i18n/i18n'
import { isBrowserPagePanePaintable } from './browser-page-paintability'
import { useMarkupMode, type MarkupCaptureContext } from './markup/useMarkupMode'
import { MarkupOverlay } from './markup/MarkupOverlay'
import { MarkupDrawButton } from './markup/MarkupDrawButton'
import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery'
type BrowserTabPageState = Partial<
Pick<
@@ -2394,6 +2398,30 @@ function RemoteBrowserPagePane({
const remoteFrameStyle = useMemo(() => getRemoteBrowserFrameStyle(frameMetadata), [frameMetadata])
// Why: markup works on remote panes by snapshotting the already-displayed
// screencast <img> — no in-page injection needed, so it is enabled here even
// though element-grab annotations are not.
const markup = useMarkupMode({
getCaptureContext: useCallback((): MarkupCaptureContext | null => {
const element = imageRef.current
const container = remoteViewportRef.current
if (!element || !container) {
return null
}
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
return null
}
return {
source: { kind: 'image', element },
cssWidth: rect.width,
cssHeight: rect.height,
outputScale: window.devicePixelRatio || 1
}
}, []),
onDeliver: deliverMarkupToClipboard
})
return (
<div className="relative flex h-full min-h-0 flex-1 flex-col bg-background">
{contextMenu
@@ -2600,12 +2628,26 @@ function RemoteBrowserPagePane({
)}
</TooltipContent>
</Tooltip>
<MarkupDrawButton
onClick={() => (markup.isActive ? markup.cancel() : void markup.start())}
disabled={!frameUrl}
active={markup.isActive}
className="h-7 w-7"
/>
</div>
<div
ref={remoteViewportRef}
tabIndex={-1}
className="relative min-h-0 flex-1 overflow-hidden bg-background"
>
{markup.isActive && markup.baseImage ? (
<MarkupOverlay
baseImage={markup.baseImage}
busy={markup.state === 'composing'}
onComplete={(input) => void markup.complete(input)}
onCancel={markup.cancel}
/>
) : null}
{frameUrl ? (
<img
ref={imageRef}
@@ -2796,6 +2838,27 @@ function BrowserPagePane({
const contextMenuRef = useRef<HTMLDivElement>(null)
const [findOpen, setFindOpen] = useState(false)
const grab = useGrabMode(browserTab.id)
const markup = useMarkupMode({
getCaptureContext: useCallback((): MarkupCaptureContext | null => {
const webview = webviewRef.current
const container = containerRef.current
if (!webview || !container) {
return null
}
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
return null
}
return {
source: { kind: 'webview', webview },
cssWidth: rect.width,
cssHeight: rect.height,
outputScale: window.devicePixelRatio || 1
}
}, []),
onDeliver: deliverMarkupToClipboard
})
const [grabIntent, setGrabIntent] = useState<GrabIntent>('copy')
const grabIntentRef = useRef(grabIntent)
grabIntentRef.current = grabIntent
@@ -4149,14 +4212,19 @@ function BrowserPagePane({
if (isEditableKeyboardTarget(e.target)) {
return
}
if (keybindingMatchesAction('browser.grabElement', e, shortcutPlatform, keybindings)) {
// Why: while the markup overlay is open, don't let the grab shortcut start
// the in-guest picker behind it — matching the disabled grab toolbar buttons.
if (
!markup.isActive &&
keybindingMatchesAction('browser.grabElement', e, shortcutPlatform, keybindings)
) {
e.preventDefault()
startGrabIntent('copy')
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isActive, keybindings, startGrabIntent])
}, [isActive, keybindings, markup.isActive, startGrabIntent])
useEffect(() => {
if (!isActive) {
@@ -4989,7 +5057,7 @@ function BrowserPagePane({
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => startGrabIntent('copy')}
disabled={isBlankTab}
disabled={isBlankTab || markup.isActive}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fdfc7fe0ef',
'Grab page element'
@@ -5026,7 +5094,7 @@ function BrowserPagePane({
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => startGrabIntent('annotate')}
disabled={isBlankTab}
disabled={isBlankTab || markup.isActive}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
@@ -5050,6 +5118,12 @@ function BrowserPagePane({
</TooltipContent>
</Tooltip>
<MarkupDrawButton
onClick={() => (markup.isActive ? markup.cancel() : void markup.start())}
disabled={isBlankTab || grab.state !== 'idle'}
active={markup.isActive}
/>
<Button
size="icon"
variant="ghost"
@@ -5379,6 +5453,14 @@ function BrowserPagePane({
{pageViewport?.container
? createPortal(
<>
{markup.isActive && markup.baseImage ? (
<MarkupOverlay
baseImage={markup.baseImage}
busy={markup.state === 'composing'}
onComplete={(input) => void markup.complete(input)}
onCancel={markup.cancel}
/>
) : null}
<div
role="status"
aria-live="polite"
@@ -0,0 +1,63 @@
import React from 'react'
import { PenTool } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useMarkupDrawHint } from './use-markup-draw-hint'
export type MarkupDrawButtonProps = {
onClick: () => void
disabled?: boolean
active?: boolean
className?: string
}
// Toolbar toggle that enters screenshot-markup mode. Shared by the local and
// remote browser panes so both expose the same affordance.
export function MarkupDrawButton({
onClick,
disabled,
active,
className
}: MarkupDrawButtonProps): React.JSX.Element {
const label = translate('auto.components.browser-pane.markup.drawButton', 'Draw on screenshot')
// Why: nudge the button the first time it's usable so users discover the tool.
const { hintOpen, dismissHint } = useMarkupDrawHint(!disabled && !active)
return (
<Tooltip open={hintOpen ? true : undefined}>
<TooltipTrigger asChild>
{/* Why: wrap in a span so tooltip hover still fires when disabled. */}
<span className="inline-flex">
<Button
size="icon"
variant={active ? 'default' : 'ghost'}
className={cn(
className ?? 'h-8 w-8',
active && 'bg-foreground/80 text-background hover:bg-foreground/90',
// Why: ring the button while the one-time discovery hint is open.
hintOpen && 'ring-2 ring-ring ring-offset-1 ring-offset-background'
)}
onClick={() => {
dismissHint()
onClick()
}}
disabled={disabled}
aria-label={label}
aria-pressed={active}
>
<PenTool className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{hintOpen
? translate(
'auto.components.browser-pane.markup.drawHint',
'New — draw on the page, then copy it to your agent'
)
: label}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,159 @@
import React, { useCallback, useRef, useState } from 'react'
import { Check, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { MarkupToolbar } from './MarkupToolbar'
import type { MarkupBaseImage } from './markup-base-image'
import type { MarkupShape } from './markup-drawing-model'
import { TEXT_FONT_FAMILY } from './markup-shape-render'
import { useMarkupEditor } from './useMarkupEditor'
export type MarkupOverlayProps = {
baseImage: MarkupBaseImage
busy: boolean
onComplete: (input: { imageElement: HTMLImageElement; shapes: MarkupShape[] }) => void
onCancel: () => void
}
export function MarkupOverlay({
baseImage,
busy,
onComplete,
onCancel
}: MarkupOverlayProps): React.JSX.Element {
const baseImgRef = useRef<HTMLImageElement | null>(null)
const [baseLoaded, setBaseLoaded] = useState(false)
const editor = useMarkupEditor(busy, onCancel)
const { pendingText } = editor
const handleDone = useCallback(() => {
const imageElement = baseImgRef.current
if (!imageElement || !baseLoaded) {
return
}
onComplete({ imageElement, shapes: editor.shapes })
}, [baseLoaded, editor.shapes, onComplete])
return (
<div
ref={editor.rootRef}
data-orca-markup-overlay
className="absolute inset-0 z-20 overflow-hidden"
>
<img
ref={baseImgRef}
src={baseImage.dataUrl}
alt=""
draggable={false}
onLoad={() => setBaseLoaded(true)}
onError={() => {
// Why: the backdrop is a self-generated data URL, so a decode failure is
// unexpected — but never trap the user with a permanently-disabled Done.
console.error('markup: base screenshot failed to load')
onCancel()
}}
className="pointer-events-none absolute inset-0 block h-full w-full select-none"
style={{ objectFit: 'fill' }}
/>
<canvas
ref={editor.canvasRef}
className={cn(
'absolute inset-0 h-full w-full touch-none',
busy ? 'cursor-progress' : 'cursor-crosshair'
)}
onPointerDown={editor.onPointerDown}
onPointerMove={editor.onPointerMove}
onPointerUp={editor.onPointerUp}
onPointerCancel={editor.onPointerUp}
/>
{pendingText ? (
<input
ref={editor.textInputRef}
// Why: key by position so each placement re-mounts a fresh input.
key={`${pendingText.x},${pendingText.y}`}
defaultValue={pendingText.initial}
aria-label={translate('auto.components.browser-pane.markup.textInput', 'Annotation text')}
onPointerDown={(event) => event.stopPropagation()}
onBlur={(event) => editor.commitPendingText(event.target.value)}
onKeyDown={(event) => {
// Why: keep keystrokes local — without this the browser pane's global
// key handlers can swallow typing before it reaches the input.
event.stopPropagation()
// Why: during IME composition (e.g. Japanese conversion), Enter
// confirms the candidate — it must NOT also commit the annotation.
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
editor.commitPendingText(event.currentTarget.value)
} else if (event.key === 'Escape') {
event.preventDefault()
editor.cancelPendingText()
}
}}
// Why: the input renders ONLY the editable glyphs (no box of its own) —
// the selection frame is drawn on the canvas so it's identical whether
// selecting or editing. No padding/border + the same font as the canvas
// so the text sits exactly where it commits. field-sizing hugs the text
// so clicks outside it still reach the canvas.
className="absolute z-30 min-w-0 border-0 bg-transparent p-0 leading-none outline-none [field-sizing:content]"
style={{
left: pendingText.x,
top: pendingText.y,
color: editor.color,
fontSize: editor.fontSize,
fontWeight: 600,
fontFamily: TEXT_FONT_FAMILY,
// Why: force line-height to 1 (the UA default ~1.2 pushes the glyph
// down) and zero the height so the text top aligns with the canvas's
// textBaseline:'top' render — otherwise editing text sits slightly low.
lineHeight: 1,
height: '1em',
boxSizing: 'content-box',
padding: 0,
textShadow:
editor.color.toLowerCase() === '#ffffff'
? '0 0 3px rgba(0,0,0,0.7)'
: '0 0 3px rgba(255,255,255,0.9), 0 0 2px rgba(255,255,255,0.9)'
}}
/>
) : null}
<div className="pointer-events-none absolute inset-x-0 bottom-3 flex flex-col items-center gap-2 px-3">
<div className="pointer-events-auto">
<MarkupToolbar
tool={editor.tool}
onToolChange={editor.setTool}
color={editor.color}
onColorChange={editor.setColor}
width={editor.width}
onWidthChange={editor.setWidth}
fontSize={editor.fontSize}
onFontSizeChange={editor.setFontSize}
canUndo={editor.canUndo}
canRedo={editor.canRedo}
onUndo={editor.undo}
onRedo={editor.redo}
onClear={editor.clear}
/>
</div>
<div className="pointer-events-auto flex items-center gap-2 rounded-md border border-border bg-card/95 p-1.5 shadow-md backdrop-blur">
<span className="px-1 text-xs text-muted-foreground">
{translate(
'auto.components.browser-pane.markup.hint',
'Draw on the page, then copy the markup to paste into your agent.'
)}
</span>
<Button type="button" variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
<X className="size-4" />
{translate('auto.components.browser-pane.markup.cancel', 'Cancel')}
</Button>
<Button type="button" size="sm" onClick={handleDone} disabled={busy || !baseLoaded}>
<Check className="size-4" />
{translate('auto.components.browser-pane.markup.copy', 'Copy Markup')}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,293 @@
import React from 'react'
import {
ArrowUpRight,
Circle,
Highlighter,
Pencil,
Redo2,
Square,
Trash2,
Type,
Undo2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
MARKUP_COLORS,
MARKUP_FONT_SIZES,
MARKUP_WIDTHS,
type MarkupTool
} from './markup-drawing-model'
type ToolItem = {
kind: MarkupTool
icon: React.ComponentType<{ className?: string }>
label: string
}
function toolItems(): ToolItem[] {
return [
{
kind: 'pen',
icon: Pencil,
label: translate('auto.components.browser-pane.markup.tool.pen', 'Pen')
},
{
kind: 'highlight',
icon: Highlighter,
label: translate('auto.components.browser-pane.markup.tool.highlight', 'Highlighter')
},
{
kind: 'arrow',
icon: ArrowUpRight,
label: translate('auto.components.browser-pane.markup.tool.arrow', 'Arrow')
},
{
kind: 'rect',
icon: Square,
label: translate('auto.components.browser-pane.markup.tool.rect', 'Rectangle')
},
{
kind: 'ellipse',
icon: Circle,
label: translate('auto.components.browser-pane.markup.tool.ellipse', 'Ellipse')
},
{
kind: 'text',
icon: Type,
label: translate('auto.components.browser-pane.markup.tool.text', 'Text')
}
]
}
export type MarkupToolbarProps = {
tool: MarkupTool
onToolChange: (tool: MarkupTool) => void
color: string
onColorChange: (color: string) => void
width: number
onWidthChange: (width: number) => void
fontSize: number
onFontSizeChange: (fontSize: number) => void
canUndo: boolean
canRedo: boolean
onUndo: () => void
onRedo: () => void
onClear: () => void
}
export const MarkupToolbar = React.memo(function MarkupToolbar({
tool,
onToolChange,
color,
onColorChange,
width,
onWidthChange,
fontSize,
onFontSizeChange,
canUndo,
canRedo,
onUndo,
onRedo,
onClear
}: MarkupToolbarProps) {
return (
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-1 rounded-md border border-border bg-card/95 px-1.5 py-1 shadow-md backdrop-blur">
{toolItems().map((item) => (
<IconButton
key={item.kind}
label={item.label}
active={tool === item.kind}
onClick={() => onToolChange(item.kind)}
>
<item.icon className="size-4" />
</IconButton>
))}
<Divider />
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate(
'auto.components.browser-pane.markup.style',
'Color and thickness'
)}
>
<span
className="size-4 rounded-full border border-border"
style={{ backgroundColor: color }}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.browser-pane.markup.style', 'Color and thickness')}
</TooltipContent>
</Tooltip>
<PopoverContent side="top" align="start" className="w-auto p-2">
<div className="flex flex-wrap gap-1">
{MARKUP_COLORS.map((swatch) => (
<button
key={swatch}
type="button"
aria-label={swatch}
onClick={() => onColorChange(swatch)}
className={cn(
'size-6 rounded-full border',
color === swatch ? 'border-ring ring-1 ring-ring' : 'border-border'
)}
style={{ backgroundColor: swatch }}
/>
))}
</div>
<div className="mt-2 flex items-center gap-1">
{MARKUP_WIDTHS.map((option) => (
<button
key={option}
type="button"
aria-label={translate(
'auto.components.browser-pane.markup.widthOption',
'{{value0}} px',
{ value0: option }
)}
onClick={() => onWidthChange(option)}
className={cn(
'flex h-6 flex-1 items-center justify-center rounded-sm border',
width === option ? 'border-ring bg-accent' : 'border-border hover:bg-accent/50'
)}
>
<span
className="rounded-full bg-foreground"
style={{ width: option + 2, height: option + 2 }}
/>
</button>
))}
</div>
</PopoverContent>
</Popover>
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 px-2"
aria-label={translate(
'auto.components.browser-pane.markup.fontSize',
'Font size'
)}
>
<Type className="size-3.5" />
<span className="text-[11px] tabular-nums">{fontSize}</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.browser-pane.markup.fontSize', 'Font size')}
</TooltipContent>
</Tooltip>
<PopoverContent side="top" align="start" className="w-auto p-2">
<div className="flex items-center gap-1">
{MARKUP_FONT_SIZES.map((size) => (
<button
key={size}
type="button"
aria-label={translate(
'auto.components.browser-pane.markup.widthOption',
'{{value0}} px',
{ value0: size }
)}
onClick={() => onFontSizeChange(size)}
className={cn(
'flex h-6 min-w-7 items-center justify-center rounded-sm border px-1 text-[11px] tabular-nums',
fontSize === size ? 'border-ring bg-accent' : 'border-border hover:bg-accent/50'
)}
>
{size}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Divider />
<IconButton
label={translate('auto.components.browser-pane.markup.undo', 'Undo')}
disabled={!canUndo}
onClick={onUndo}
>
<Undo2 className="size-4" />
</IconButton>
<IconButton
label={translate('auto.components.browser-pane.markup.redo', 'Redo')}
disabled={!canRedo}
onClick={onRedo}
>
<Redo2 className="size-4" />
</IconButton>
<IconButton
label={translate('auto.components.browser-pane.markup.clear', 'Clear all')}
disabled={!canUndo && !canRedo}
onClick={onClear}
>
<Trash2 className="size-4" />
</IconButton>
</div>
</TooltipProvider>
)
})
function Divider(): React.JSX.Element {
return <span className="mx-0.5 h-5 w-px bg-border" aria-hidden />
}
type IconButtonProps = {
label: string
active?: boolean
disabled?: boolean
onClick: () => void
children: React.ReactNode
}
function IconButton({
label,
active,
disabled,
onClick,
children
}: IconButtonProps): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={active ? 'default' : 'ghost'}
size="icon-sm"
disabled={disabled}
aria-label={label}
aria-pressed={active}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,78 @@
// Captures the frozen base screenshot the user draws on. Renderer-only and
// environment-aware: local Electron webviews expose capturePage(); remote panes
// already display the streamed frame as an <img> we can snapshot. Keeping this
// out of the main process means the markup feature works identically for local
// and remote/SSH browser panes.
// A frozen snapshot as a data URL plus its intrinsic pixel size. The overlay
// shows it as an <img> backdrop and reuses that same loaded element as the
// compositor's base layer.
export type MarkupBaseImage = {
dataUrl: string
width: number
height: number
}
export type MarkupCaptureSource =
| { kind: 'webview'; webview: Electron.WebviewTag }
| { kind: 'image'; element: HTMLImageElement }
export async function captureMarkupBaseImage(
source: MarkupCaptureSource
): Promise<MarkupBaseImage> {
if (source.kind === 'webview') {
return captureFromWebview(source.webview)
}
return captureFromImage(source.element)
}
async function captureFromWebview(webview: Electron.WebviewTag): Promise<MarkupBaseImage> {
const native = await webview.capturePage()
if (native.isEmpty()) {
throw new Error('markup: webview capturePage returned an empty image')
}
const size = native.getSize()
// Why: capturePage() can include an alpha channel where the page has no opaque
// background, which would let the live webview ghost through the frozen
// backdrop. Flatten onto white (the browser's default canvas color) so the
// base image is fully opaque.
const image = await loadImage(native.toDataURL())
return { dataUrl: flattenToOpaquePng(image, size.width, size.height), ...size }
}
function captureFromImage(element: HTMLImageElement): MarkupBaseImage {
const width = element.naturalWidth || element.width
const height = element.naturalHeight || element.height
// Why: drawImage() silently no-ops on an undecoded/detached source, yielding a
// blank PNG. Fail fast instead so the caller surfaces an error.
if (!element.complete || !element.isConnected || width <= 0 || height <= 0) {
throw new Error('markup: remote frame image is not ready')
}
// Why: snapshot the live frame into a detached canvas (opaque white backdrop)
// so later screencast frames can't change the base image and transparent
// regions don't ghost the live stream through.
return { dataUrl: flattenToOpaquePng(element, width, height), width, height }
}
function flattenToOpaquePng(image: CanvasImageSource, width: number, height: number): string {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('markup: 2d context unavailable for base image')
}
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(image, 0, 0, width, height)
return canvas.toDataURL('image/png')
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('markup: failed to decode captured screenshot'))
image.src = src
})
}
@@ -0,0 +1,88 @@
// Renders the live markup scene into the overlay canvas at device resolution.
// Committed shapes are rasterized once into an offscreen layer; the per-frame
// paint blits that layer and draws only the in-progress shape on top, so a fast
// pointermove stream never re-strokes the whole scene. Kept separate from the
// editor hook so the draw composition stays focused.
//
// Why the caller passes dpr: the committed layer and the visible canvas are sized
// in two separate paints. Reading window.devicePixelRatio independently in each
// could size them differently if the ratio changed between them (monitor move),
// misaligning the 1:1 blit. Taking one measured dpr keeps them in lockstep.
import { clampMarkupScale } from './markup-screenshot-compose'
import { drawShapes } from './markup-shape-render'
import type { MarkupShape } from './markup-drawing-model'
function sceneDevicePixels(
cssWidth: number,
cssHeight: number,
dpr: number
): { scale: number; width: number; height: number } {
// Why: clamp identically to the compositor so the live preview and the exported
// PNG render shapes at the same scale.
const scale = clampMarkupScale(dpr)
return {
scale,
width: Math.max(1, Math.round(cssWidth * scale)),
height: Math.max(1, Math.round(cssHeight * scale))
}
}
// Rasterizes the committed shapes into an offscreen layer. Called only when the
// committed shape list, the size, or the dpr changes — never on the pointermove
// path.
export function renderCommittedLayer(
layer: HTMLCanvasElement,
shapes: readonly MarkupShape[],
cssWidth: number,
cssHeight: number,
dpr: number
): void {
const { scale, width, height } = sceneDevicePixels(cssWidth, cssHeight, dpr)
if (layer.width !== width || layer.height !== height) {
layer.width = width
layer.height = height
}
const ctx = layer.getContext('2d')
if (!ctx) {
return
}
ctx.setTransform(scale, 0, 0, scale, 0, 0)
ctx.clearRect(0, 0, cssWidth, cssHeight)
drawShapes(ctx, shapes)
}
// Blits the cached committed layer plus the in-progress shape onto the visible
// canvas. Cheap enough for every frame: one image blit + at most one shape.
export function blitMarkupScene(
canvas: HTMLCanvasElement,
committedLayer: HTMLCanvasElement,
inProgress: MarkupShape | null,
cssWidth: number,
cssHeight: number,
dpr: number
): void {
const ctx = canvas.getContext('2d')
if (!ctx) {
return
}
const { scale, width, height } = sceneDevicePixels(cssWidth, cssHeight, dpr)
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
}
// Why: blit the committed layer under the identity transform, stretched to the
// canvas's device box. It's 1:1 when the layer matches (the common case, since
// both are sized from the same dpr) and degrades gracefully — never clips or
// offsets — if a layer render briefly lags a dpr change.
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, width, height)
if (committedLayer.width > 0 && committedLayer.height > 0) {
ctx.drawImage(committedLayer, 0, 0, width, height)
}
// Then switch to the dpr transform to draw the in-progress shape in CSS coords.
ctx.setTransform(scale, 0, 0, scale, 0, 0)
if (inProgress) {
drawShapes(ctx, [inProgress])
}
}
@@ -0,0 +1,21 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type { MarkupComposeResult } from './markup-screenshot-compose'
// Delivery for v1: copy the composited markup PNG to the clipboard, mirroring the
// browser grab "copy" flow. The user pastes it into their agent terminal, where
// Orca's existing clipboard-screenshot paste writes the image to a temp file (on
// the correct host for local or remote/SSH agents) and hands the path to the TUI.
// This reuses proven, environment-agnostic machinery instead of re-plumbing a
// direct send.
export async function deliverMarkupToClipboard(result: MarkupComposeResult): Promise<void> {
await window.api.ui.writeClipboardImage(result.dataUrl)
const isMac = navigator.userAgent.includes('Mac')
toast.success(
translate(
'auto.components.browser-pane.markup.copiedToast',
'Markup copied — paste it into your agent ({{value0}})',
{ value0: isMac ? '⌘V' : 'Ctrl+V' }
)
)
}
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import {
arrowHeadGeometry,
canRedo,
canUndo,
clearShapes,
commitShape,
createMarkupDocument,
isEmptyDocument,
normalizeRect,
redoShape,
scaleShape,
setShapes,
undoShape,
type ArrowShape,
type MarkupShape,
type PenShape,
type TextShape
} from './markup-drawing-model'
function pen(id: string): PenShape {
return { id, kind: 'pen', color: '#ef4444', width: 4, points: [{ x: 0, y: 0 }] }
}
describe('markup document undo/redo', () => {
it('starts empty', () => {
const doc = createMarkupDocument()
expect(doc.shapes).toHaveLength(0)
expect(isEmptyDocument(doc)).toBe(true)
expect(canUndo(doc)).toBe(false)
expect(canRedo(doc)).toBe(false)
})
it('commits shapes and clears the redo stack', () => {
let doc = createMarkupDocument()
doc = commitShape(doc, pen('a'))
doc = commitShape(doc, pen('b'))
expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'b'])
expect(canUndo(doc)).toBe(true)
expect(canRedo(doc)).toBe(false)
})
it('undoes and redoes in order', () => {
let doc = createMarkupDocument()
doc = commitShape(doc, pen('a'))
doc = commitShape(doc, pen('b'))
doc = undoShape(doc)
expect(doc.shapes.map((s) => s.id)).toEqual(['a'])
expect(canRedo(doc)).toBe(true)
doc = redoShape(doc)
expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'b'])
expect(canRedo(doc)).toBe(false)
})
it('forks history: committing after undo discards the redo stack', () => {
let doc = createMarkupDocument()
doc = commitShape(doc, pen('a'))
doc = commitShape(doc, pen('b'))
doc = undoShape(doc)
doc = commitShape(doc, pen('c'))
expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'c'])
expect(canRedo(doc)).toBe(false)
})
it('undo/redo on empty document are no-ops returning the same reference', () => {
const doc = createMarkupDocument()
expect(undoShape(doc)).toBe(doc)
expect(redoShape(doc)).toBe(doc)
})
it('clears shapes, and is a no-op (same reference) when already empty', () => {
const empty = createMarkupDocument()
expect(clearShapes(empty)).toBe(empty)
let doc = commitShape(empty, pen('a'))
doc = clearShapes(doc)
expect(doc.shapes).toHaveLength(0)
expect(canRedo(doc)).toBe(false)
})
it('undoes an edit (not just an add) via whole-list snapshots', () => {
let doc = commitShape(createMarkupDocument(), pen('a'))
doc = setShapes(doc, []) // delete everything
expect(doc.shapes).toHaveLength(0)
doc = undoShape(doc)
expect(doc.shapes.map((s) => s.id)).toEqual(['a'])
})
})
describe('markup geometry', () => {
it('normalizes rectangles from any drag direction', () => {
expect(normalizeRect({ x: 10, y: 20 }, { x: 4, y: 50 })).toEqual({
x: 4,
y: 20,
width: 6,
height: 30
})
})
it('scales pen points and width', () => {
const shape: PenShape = {
id: 'a',
kind: 'pen',
color: '#fff',
width: 4,
points: [
{ x: 2, y: 3 },
{ x: 4, y: 5 }
]
}
const scaled = scaleShape(shape, 2) as PenShape
expect(scaled.width).toBe(8)
expect(scaled.points).toEqual([
{ x: 4, y: 6 },
{ x: 8, y: 10 }
])
})
it('scales rect/arrow endpoints and text position + font size', () => {
const arrow: ArrowShape = {
id: 'a',
kind: 'arrow',
color: '#fff',
width: 3,
from: { x: 1, y: 1 },
to: { x: 3, y: 5 }
}
const scaledArrow = scaleShape(arrow, 3) as ArrowShape
expect(scaledArrow.from).toEqual({ x: 3, y: 3 })
expect(scaledArrow.to).toEqual({ x: 9, y: 15 })
expect(scaledArrow.width).toBe(9)
const text: TextShape = {
id: 't',
kind: 'text',
color: '#fff',
at: { x: 10, y: 20 },
text: 'fix',
fontSize: 18
}
const scaledText = scaleShape(text, 2) as TextShape
expect(scaledText.at).toEqual({ x: 20, y: 40 })
expect(scaledText.fontSize).toBe(36)
})
it('returns null arrowhead for a zero-length segment', () => {
expect(arrowHeadGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 4)).toBeNull()
})
it('produces a symmetric arrowhead pointing at the tip', () => {
const head = arrowHeadGeometry({ x: 0, y: 0 }, { x: 10, y: 0 }, 4)
expect(head).not.toBeNull()
if (!head) {
return
}
expect(head.tip).toEqual({ x: 10, y: 0 })
// Wings sit behind the tip (smaller x) and mirror across the horizontal axis.
expect(head.left.x).toBeLessThan(10)
expect(head.right.x).toBeLessThan(10)
expect(head.left.y).toBeCloseTo(-head.right.y, 6)
})
})
// Why: exhaustiveness guard — if a new MarkupShape kind is added without a
// scaleShape branch, this fails to type-check at the cast site below.
const _exhaustive: MarkupShape['kind'][] = ['pen', 'highlight', 'arrow', 'rect', 'ellipse', 'text']
void _exhaustive
@@ -0,0 +1,213 @@
// Pure drawing model for browser screenshot markup. No DOM access — shapes are
// vector objects in CSS-viewport coordinates so the same data drives both the
// live canvas overlay and the final composited PNG. Keeping this canvas-free
// makes the undo/redo and geometry logic unit-testable.
export type MarkupToolKind = 'pen' | 'highlight' | 'arrow' | 'rect' | 'ellipse' | 'text'
// Toolbar selection. Draw-only: markup is a throwaway scribble the user copies
// once, so there is no select/move/restyle cursor.
export type MarkupTool = MarkupToolKind
export type MarkupPoint = { x: number; y: number }
type MarkupShapeBase = { id: string; color: string }
export type PenShape = MarkupShapeBase & { kind: 'pen'; points: MarkupPoint[]; width: number }
export type HighlightShape = MarkupShapeBase & {
kind: 'highlight'
points: MarkupPoint[]
width: number
}
export type ArrowShape = MarkupShapeBase & {
kind: 'arrow'
from: MarkupPoint
to: MarkupPoint
width: number
}
export type RectShape = MarkupShapeBase & {
kind: 'rect'
from: MarkupPoint
to: MarkupPoint
width: number
}
export type EllipseShape = MarkupShapeBase & {
kind: 'ellipse'
from: MarkupPoint
to: MarkupPoint
width: number
}
export type TextShape = MarkupShapeBase & {
kind: 'text'
at: MarkupPoint
text: string
fontSize: number
}
export type MarkupShape =
| PenShape
| HighlightShape
| ArrowShape
| RectShape
| EllipseShape
| TextShape
// Concrete ink colors (baked into the exported PNG, so these are content colors,
// not UI-chrome tokens). Ordered for a compact swatch row.
export const MARKUP_COLORS = [
'#ef4444',
'#f97316',
'#eab308',
'#22c55e',
'#3b82f6',
'#111827',
'#ffffff'
] as const
export const DEFAULT_MARKUP_COLOR: string = MARKUP_COLORS[0]
export const MARKUP_WIDTHS = [2, 4, 8] as const
export const DEFAULT_MARKUP_WIDTH = 4
export const MARKUP_FONT_SIZES = [14, 18, 24, 32, 48] as const
export const DEFAULT_MARKUP_FONT_SIZE = 18
// Highlight strokes are intentionally fat and translucent.
export const HIGHLIGHT_WIDTH_MULTIPLIER = 4
export const HIGHLIGHT_ALPHA = 0.35
// ─── Document with undo/redo ────────────────────────────────────────────────
export type MarkupDocument = {
shapes: MarkupShape[]
/** Prior whole-list states (oldest first) for undo. */
past: MarkupShape[][]
/** Undone whole-list states (newest first) for redo. */
future: MarkupShape[][]
}
export function createMarkupDocument(): MarkupDocument {
return { shapes: [], past: [], future: [] }
}
// Replace the whole shape list as one undoable step. Snapshot-based so every
// edit — add, move, restyle, delete — is reversible, not just adding a shape.
export function setShapes(doc: MarkupDocument, shapes: MarkupShape[]): MarkupDocument {
return { shapes, past: [...doc.past, doc.shapes], future: [] }
}
export function commitShape(doc: MarkupDocument, shape: MarkupShape): MarkupDocument {
return setShapes(doc, [...doc.shapes, shape])
}
export function undoShape(doc: MarkupDocument): MarkupDocument {
const prev = doc.past.at(-1)
if (!prev) {
return doc
}
return { shapes: prev, past: doc.past.slice(0, -1), future: [doc.shapes, ...doc.future] }
}
export function redoShape(doc: MarkupDocument): MarkupDocument {
const next = doc.future.at(0)
if (!next) {
return doc
}
return { shapes: next, past: [...doc.past, doc.shapes], future: doc.future.slice(1) }
}
export function clearShapes(doc: MarkupDocument): MarkupDocument {
return doc.shapes.length === 0 ? doc : setShapes(doc, [])
}
export function canUndo(doc: MarkupDocument): boolean {
return doc.past.length > 0
}
export function canRedo(doc: MarkupDocument): boolean {
return doc.future.length > 0
}
export function isEmptyDocument(doc: MarkupDocument): boolean {
return doc.shapes.length === 0
}
// ─── Geometry helpers (shared by live overlay + compositor) ─────────────────
export type NormalizedRect = { x: number; y: number; width: number; height: number }
export function normalizeRect(from: MarkupPoint, to: MarkupPoint): NormalizedRect {
return {
x: Math.min(from.x, to.x),
y: Math.min(from.y, to.y),
width: Math.abs(to.x - from.x),
height: Math.abs(to.y - from.y)
}
}
export function scalePoint(point: MarkupPoint, scale: number): MarkupPoint {
return { x: point.x * scale, y: point.y * scale }
}
// Why: the compositor draws into a canvas sized to the base-image's physical
// pixels, while shapes are authored in CSS-viewport pixels. Scaling every shape
// by (imagePx / cssPx) keeps strokes aligned with the underlying screenshot.
export function scaleShape(shape: MarkupShape, scale: number): MarkupShape {
switch (shape.kind) {
case 'pen':
case 'highlight':
return {
...shape,
width: shape.width * scale,
points: shape.points.map((point) => scalePoint(point, scale))
}
case 'arrow':
case 'rect':
case 'ellipse':
return {
...shape,
width: shape.width * scale,
from: scalePoint(shape.from, scale),
to: scalePoint(shape.to, scale)
}
case 'text':
return {
...shape,
fontSize: shape.fontSize * scale,
at: scalePoint(shape.at, scale)
}
}
}
export type ArrowHeadGeometry = { tip: MarkupPoint; left: MarkupPoint; right: MarkupPoint }
const ARROW_HEAD_ANGLE = 0.45
export function arrowHeadLength(width: number): number {
return Math.max(10, width * 3.5)
}
// Returns the two wing points of an arrowhead at `to`, or null when the segment
// has no direction (from === to) so callers can skip drawing a degenerate head.
export function arrowHeadGeometry(
from: MarkupPoint,
to: MarkupPoint,
width: number
): ArrowHeadGeometry | null {
const dx = to.x - from.x
const dy = to.y - from.y
if (dx === 0 && dy === 0) {
return null
}
const angle = Math.atan2(dy, dx)
const size = arrowHeadLength(width)
const leftAngle = angle + Math.PI - ARROW_HEAD_ANGLE
const rightAngle = angle + Math.PI + ARROW_HEAD_ANGLE
return {
tip: to,
left: { x: to.x + size * Math.cos(leftAngle), y: to.y + size * Math.sin(leftAngle) },
right: { x: to.x + size * Math.cos(rightAngle), y: to.y + size * Math.sin(rightAngle) }
}
}
export function highlightWidth(width: number): number {
return width * HIGHLIGHT_WIDTH_MULTIPLIER
}
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
clampMarkupScale,
dataUrlByteLength,
effectiveMarkupScale,
markupCanvasSize,
MARKUP_DOWNSCALE_STEPS
} from './markup-screenshot-compose'
describe('clampMarkupScale', () => {
it('keeps sane scales and clamps to [1, 4]', () => {
expect(clampMarkupScale(2)).toBe(2)
expect(clampMarkupScale(0.5)).toBe(1)
expect(clampMarkupScale(10)).toBe(4)
})
it('guards against non-finite / non-positive values', () => {
expect(clampMarkupScale(0)).toBe(1)
expect(clampMarkupScale(-3)).toBe(1)
expect(clampMarkupScale(Number.NaN)).toBe(1)
})
})
describe('markupCanvasSize', () => {
it('sizes the output to the content box times the (clamped) scale', () => {
expect(markupCanvasSize(800, 600, 2)).toEqual({ width: 1600, height: 1200 })
expect(markupCanvasSize(800, 600, 0.5)).toEqual({ width: 800, height: 600 })
})
it('keeps the composite within the pixel budget (never over)', () => {
// A large HiDPI pane: 4000×3000 CSS × dpr 2 = 48M px > the ~33.55M (32×1024×1024) ceiling.
const maxPixels = 32 * 1024 * 1024
const size = markupCanvasSize(4000, 3000, 2, maxPixels)
expect(size.width * size.height).toBeLessThanOrEqual(maxPixels)
// Aspect ratio is preserved (one uniform scale).
expect(size.width / size.height).toBeCloseTo(4000 / 3000, 3)
})
})
describe('effectiveMarkupScale', () => {
it('returns the clamped scale when the composite fits the pixel budget', () => {
expect(effectiveMarkupScale(800, 600, 2, 32 * 1024 * 1024)).toBe(2)
})
it('reduces the scale so area stays within the pixel budget', () => {
const scale = effectiveMarkupScale(4000, 3000, 2, 32 * 1024 * 1024)
expect(scale).toBeLessThan(2)
expect(4000 * scale * (3000 * scale)).toBeLessThanOrEqual(32 * 1024 * 1024 + 1)
})
it('defaults to the clamped scale with no pixel budget', () => {
expect(effectiveMarkupScale(3840, 2160, 2)).toBe(2)
})
})
describe('dataUrlByteLength', () => {
it('decodes the base64 payload size', () => {
// "hi" -> base64 "aGk=" (1 pad char) -> 2 bytes
expect(dataUrlByteLength('data:image/png;base64,aGk=')).toBe(2)
// "man" -> "bWFu" (no padding) -> 3 bytes
expect(dataUrlByteLength('data:image/png;base64,bWFu')).toBe(3)
})
it('returns 0 for a malformed data url', () => {
expect(dataUrlByteLength('not-a-data-url')).toBe(0)
})
})
describe('compose budget plans', () => {
it('tries full size first, then shrinks (PNG only, descending)', () => {
expect(MARKUP_DOWNSCALE_STEPS[0]).toBe(1)
expect([...MARKUP_DOWNSCALE_STEPS]).toEqual([...MARKUP_DOWNSCALE_STEPS].sort((a, b) => b - a))
expect(MARKUP_DOWNSCALE_STEPS.every((s) => s > 0 && s <= 1)).toBe(true)
})
})
@@ -0,0 +1,201 @@
// Composites the frozen base screenshot + markup shapes into a single PNG under
// the delivery byte and pixel budgets (PNG only — see MarkupComposeResult).
//
// WYSIWYG: the output canvas matches the on-screen content box (its CSS width ×
// height) times an output scale for crispness. The base image is stretched to
// fill that box exactly as the user saw it (so a distorted objectFit:fill remote
// frame composites correctly), and shapes — authored in CSS coordinates — are
// scaled by the same single factor. One uniform scale keeps strokes aligned.
import { scaleShape, type MarkupShape } from './markup-drawing-model'
import { drawShapes } from './markup-shape-render'
export type MarkupComposeResult = {
dataUrl: string
// Why: PNG only — the clipboard:writeImage handler accepts a PNG data URL and
// silently drops anything else, so a JPEG fallback would "succeed" with an
// empty clipboard.
mimeType: 'image/png'
width: number
height: number
byteLength: number
}
export type MarkupComposeInput = {
image: CanvasImageSource
/** On-screen size of the content box the canvas overlaid (CSS pixels). */
displayCssWidth: number
displayCssHeight: number
/** Output resolution multiplier (typically devicePixelRatio). */
outputScale: number
shapes: readonly MarkupShape[]
/** Hard byte ceiling for the encoded data URL payload. */
maxBytes: number
/** Hard pixel (width×height) ceiling — the clipboard handler silently drops
* images over its own dimension limit, so the composite must fit it too. */
maxPixels: number
}
// ─── Pure helpers (unit-tested) ─────────────────────────────────────────────
// Keep the output scale sane so a huge DPR or bogus value can't allocate an
// absurd canvas.
export function clampMarkupScale(scale: number): number {
if (!Number.isFinite(scale) || scale <= 0) {
return 1
}
return Math.min(Math.max(scale, 1), 4)
}
// The single uniform scale actually applied to the base image and every shape:
// the clamped output scale, reduced further if needed so the composite's pixel
// area stays within `maxPixels`. Reducing one uniform factor keeps strokes aligned.
export function effectiveMarkupScale(
displayCssWidth: number,
displayCssHeight: number,
outputScale: number,
maxPixels: number = Number.POSITIVE_INFINITY
): number {
const scale = clampMarkupScale(outputScale)
const area = displayCssWidth * displayCssHeight
if (area <= 0 || !Number.isFinite(maxPixels) || maxPixels <= 0) {
return scale
}
return Math.min(scale, Math.sqrt(maxPixels / area))
}
export function markupCanvasSize(
displayCssWidth: number,
displayCssHeight: number,
outputScale: number,
maxPixels: number = Number.POSITIVE_INFINITY
): { width: number; height: number } {
const scale = effectiveMarkupScale(displayCssWidth, displayCssHeight, outputScale, maxPixels)
// Why: floor (not round) so a rounded-up dimension can't nudge the area back
// over maxPixels at the budget boundary.
return {
width: Math.max(1, Math.floor(displayCssWidth * scale)),
height: Math.max(1, Math.floor(displayCssHeight * scale))
}
}
// Bytes represented by a `data:...;base64,<payload>` URL's payload.
export function dataUrlByteLength(dataUrl: string): number {
const commaIndex = dataUrl.indexOf(',')
if (commaIndex === -1) {
return 0
}
const payload = dataUrl.slice(commaIndex + 1)
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding)
}
// Progressive raster sizes (full, then shrink) tried to fit the byte budget.
// PNG at every step — never JPEG (see MarkupComposeResult).
export const MARKUP_DOWNSCALE_STEPS = [1, 0.85, 0.7, 0.55, 0.4, 0.3] as const
// ─── Canvas raster (thin) ───────────────────────────────────────────────────
function createCanvas(width: number, height: number): HTMLCanvasElement {
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(width))
canvas.height = Math.max(1, Math.round(height))
return canvas
}
function renderComposite(input: MarkupComposeInput): HTMLCanvasElement {
const scale = effectiveMarkupScale(
input.displayCssWidth,
input.displayCssHeight,
input.outputScale,
input.maxPixels
)
const size = markupCanvasSize(
input.displayCssWidth,
input.displayCssHeight,
input.outputScale,
input.maxPixels
)
const canvas = createCanvas(size.width, size.height)
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('markup compose: 2d context unavailable')
}
ctx.drawImage(input.image, 0, 0, size.width, size.height)
for (const shape of input.shapes) {
drawShapes(ctx, [scaleShape(shape, scale)])
}
return canvas
}
function downscaleCanvas(source: HTMLCanvasElement, factor: number): HTMLCanvasElement {
if (factor >= 1) {
return source
}
const next = createCanvas(source.width * factor, source.height * factor)
const ctx = next.getContext('2d')
if (!ctx) {
return source
}
ctx.drawImage(source, 0, 0, next.width, next.height)
return next
}
// Encodes a canvas to a PNG data URL. Prefers toBlob because Chromium encodes it
// off the main thread — a multi-megapixel toData('image/png') is synchronous and
// freezes the renderer. Falls back to the sync encode if toBlob yields no blob.
function canvasToPngDataUrl(canvas: HTMLCanvasElement): Promise<string> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
try {
resolve(canvas.toDataURL('image/png'))
} catch (error) {
reject(error instanceof Error ? error : new Error('markup compose: toDataURL failed'))
}
return
}
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error ?? new Error('markup compose: blob read failed'))
reader.readAsDataURL(blob)
}, 'image/png')
})
}
// Encodes a PNG within budget: full size, then progressively smaller. Always
// returns a PNG (best effort at the smallest step) so the clipboard handler
// accepts it and delivery never silently drops. Async so the per-step PNG encode
// runs off the main thread instead of blocking the renderer.
export async function composeMarkupDataUrl(
input: MarkupComposeInput
): Promise<MarkupComposeResult> {
const composite = renderComposite(input)
let smallest: MarkupComposeResult | null = null
for (const step of MARKUP_DOWNSCALE_STEPS) {
const canvas = downscaleCanvas(composite, step)
const dataUrl = await canvasToPngDataUrl(canvas)
const byteLength = dataUrlByteLength(dataUrl)
const result: MarkupComposeResult = {
dataUrl,
mimeType: 'image/png',
width: canvas.width,
height: canvas.height,
byteLength
}
if (byteLength <= input.maxBytes) {
return result
}
smallest = result
}
// Why: still over the byte budget at the smallest step — return it anyway. It's
// a PNG within the pixel budget (every step is), so the clipboard handler
// accepts it; a slightly large attachment beats dropping the user's markup.
if (!smallest) {
// Unreachable: MARKUP_DOWNSCALE_STEPS is non-empty, so the loop always sets it.
throw new Error('markup compose: no downscale step produced output')
}
return smallest
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import { drawShape } from './markup-shape-render'
import { HIGHLIGHT_ALPHA, type MarkupShape } from './markup-drawing-model'
// Minimal recording stand-in for CanvasRenderingContext2D — drawShape only uses
// this subset, so we can assert the dispatch without a real canvas.
function makeRecordingCtx() {
const calls: { method: string; args: unknown[] }[] = []
const record =
(method: string) =>
(...args: unknown[]) => {
calls.push({ method, args })
}
const ctx = {
save: record('save'),
restore: record('restore'),
beginPath: record('beginPath'),
moveTo: record('moveTo'),
lineTo: record('lineTo'),
arc: record('arc'),
ellipse: record('ellipse'),
stroke: record('stroke'),
fill: record('fill'),
strokeRect: record('strokeRect'),
fillText: record('fillText'),
strokeText: record('strokeText'),
lineCap: '',
lineJoin: '',
strokeStyle: '',
fillStyle: '',
lineWidth: 0,
globalAlpha: 1,
font: '',
textBaseline: ''
}
const methods = (name: string) => calls.filter((c) => c.method === name)
return { ctx: ctx as unknown as CanvasRenderingContext2D, calls, methods }
}
describe('drawShape dispatch', () => {
it('strokes a multi-point pen as a polyline', () => {
const { ctx, methods } = makeRecordingCtx()
const shape: MarkupShape = {
id: 'p',
kind: 'pen',
color: '#ef4444',
width: 4,
points: [
{ x: 0, y: 0 },
{ x: 5, y: 5 },
{ x: 9, y: 1 }
]
}
drawShape(ctx, shape)
expect(methods('moveTo')).toHaveLength(1)
expect(methods('lineTo')).toHaveLength(2)
expect(methods('stroke')).toHaveLength(1)
})
it('draws a dot for a single-point pen', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, { id: 'p', kind: 'pen', color: '#fff', width: 4, points: [{ x: 3, y: 3 }] })
expect(methods('arc')).toHaveLength(1)
expect(methods('fill')).toHaveLength(1)
})
it('applies translucent alpha for highlights', () => {
const { ctx } = makeRecordingCtx()
drawShape(ctx, {
id: 'h',
kind: 'highlight',
color: '#eab308',
width: 4,
points: [
{ x: 0, y: 0 },
{ x: 8, y: 0 }
]
})
expect(ctx.globalAlpha).toBe(HIGHLIGHT_ALPHA)
})
it('draws an arrow as a shaft plus a head', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, {
id: 'a',
kind: 'arrow',
color: '#fff',
width: 4,
from: { x: 0, y: 0 },
to: { x: 20, y: 0 }
})
// shaft (moveTo+lineTo) + head (moveTo+lineTo+lineTo)
expect(methods('moveTo').length).toBeGreaterThanOrEqual(2)
expect(methods('stroke').length).toBeGreaterThanOrEqual(2)
})
it('uses strokeRect for rectangles', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, {
id: 'r',
kind: 'rect',
color: '#fff',
width: 2,
from: { x: 10, y: 10 },
to: { x: 4, y: 30 }
})
expect(methods('strokeRect')).toHaveLength(1)
expect(methods('strokeRect')[0].args).toEqual([4, 10, 6, 20])
})
it('uses ellipse for ovals', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, {
id: 'e',
kind: 'ellipse',
color: '#fff',
width: 2,
from: { x: 0, y: 0 },
to: { x: 20, y: 10 }
})
expect(methods('ellipse')).toHaveLength(1)
expect(methods('ellipse')[0].args.slice(0, 4)).toEqual([10, 5, 10, 5])
})
it('renders single-line text with a halo (stroke behind fill)', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, {
id: 't',
kind: 'text',
color: '#fff',
at: { x: 5, y: 5 },
text: 'fix this',
fontSize: 18
})
expect(methods('strokeText')).toHaveLength(1)
expect(methods('fillText')).toHaveLength(1)
expect(methods('fillText')[0].args[0]).toBe('fix this')
})
it('save/restore brackets every shape so state never leaks', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, {
id: 'r',
kind: 'rect',
color: '#fff',
width: 2,
from: { x: 0, y: 0 },
to: { x: 1, y: 1 }
})
expect(methods('save')).toHaveLength(1)
expect(methods('restore')).toHaveLength(1)
})
})

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