mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-mobile-terminal-blank
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+3
-3
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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
@@ -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 } : {})
|
||||
})
|
||||
|
||||
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user