diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0826c5dea46..8a6dc2b0d96 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5063,6 +5063,129 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-session.shell-owned-mode-recovery", + "title": "Dead TUI mode recovery requires execution-host shell ownership", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "host-renderer-electron", + "surfaces": [ + "alternate-screen lifecycle", + "mouse input routing", + "hidden-output snapshot restore", + "daemon reattach", + "remote-runtime replay", + "durable terminal history" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon", "remote-runtime"], + "coverageNotes": "Deterministic host/parser, process-classifier, snapshot-publication, mixed-version, renderer, and macOS Electron coverage proves the local and shared paired-runtime contracts. Direct SSH intentionally publishes no shell proof and immediately preserves incumbent behavior. WSL, Windows ConPTY, Linux desktop, and paired headed/headless journeys remain gaps.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/16379"], + "invariant": "After a TUI exits or is killed, reveal, reattach, snapshot replay, or renderer remount must not deliver terminal-owned mouse or alternate-screen protocol bytes to the surviving shell. Recovery is an ordered output barrier in the daemon session data path: an OSC 133;D completing while the alternate screen is still active pauses the stream at that exact byte boundary, a fresh execution-host process inspection proves shell ownership, and on proof a mode reset is injected as in-stream output so every consumer converges by parsing the same bytes and the queued post-boundary shell output (the prompt) lands on the normal buffer. Snapshots are pure reads. Any failure — refuted proof, timeout, queue overflow, session death, disposal — flushes the queue unmodified, preserving incumbent behavior; later command or mode bytes revoke proof. Clean alternate-screen exits prove ownership asynchronously without pausing.", + "oracle": "Run one fixed child-TUI journey for normal exit and cleanup-free SIGKILL. Assert renderer and host normal-buffer/non-mouse state, host snapshot terminalOwner metadata, exact PTY writes with no post-exit mouse report, unrelated-pane survival, post-boundary prompt output preserved (normal exit), ordered proof invalidation, bounded settlement and bail-out flush, one inspection per unclean episode with zero scans for ordinary output, split-escape safety at every chunk boundary, and old/new client-host fallback parity.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/terminal-shell-lifecycle-scanner.test.ts src/main/daemon/terminal-shell-recovery-barrier.test.ts src/main/daemon/session-shell-recovery.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-restore-scrollback-depth.test.ts src/main/daemon/terminal-checkpoint-serializer.test.ts src/main/providers/agent-foreground-process.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/mobile-subscribe-integration.test.ts src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts", + "src/main/daemon/terminal-shell-recovery-barrier.test.ts", + "src/main/daemon/session-shell-recovery.test.ts", + "src/main/daemon/session.test.ts", + "src/main/daemon/terminal-host-concurrent-create.test.ts", + "src/main/daemon/daemon-pty-adapter.test.ts", + "src/main/daemon/daemon-restore-scrollback-depth.test.ts", + "src/main/daemon/terminal-checkpoint-serializer.test.ts", + "src/main/providers/agent-foreground-process.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/mobile-subscribe-integration.test.ts", + "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts", + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts", + "tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts", + "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/terminal-shell-recovery-barrier.test.ts", + "assertions": [ + "an unclean-death boundary pauses the stream, injects the reset in order, and delivers the queued prompt after it", + "refuted proof, timeout, overflow, death, and disposal all flush the queue unmodified with no injection", + "clean alternate-screen exits prove ownership without pausing and later command starts revoke proof", + "a second unclean episode queued behind the first resolves in order", + "split triggers across emissions split the completing emission at the exact sequence boundary" + ] + }, + { + "file": "src/main/daemon/session-shell-recovery.test.ts", + "assertions": [ + "post-kill shell output survives into the normalized snapshot instead of the discarded alt buffer", + "a snapshot taken during a split escape keeps the pending tail intact and stale proof is revoked by the completing bytes" + ] + }, + { + "file": "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts", + "assertions": [ + "normal exit and cleanup-free SIGKILL both restore a visible clean shell marker", + "renderer logical buffer is normal and mouse tracking is disabled after host-proven recovery", + "real pointer input writes no SGR mouse report to the surviving shell", + "an unrelated pane remains live" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 33.8, + "summary": "Both normal-exit and SIGKILL journeys passed on the ordered-output-barrier implementation; each asserted renderer and host normal modes, host ownership metadata, exact PTY input, no post-exit SGR mouse report under real pointer input, and unrelated-pane survival, with post-boundary prompt output additionally proven visible on normal exit." + } + ], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "focused host/renderer contracts, mixed-version journey, and two-case Electron run" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "The deterministic focused suites and two-case Electron journey pass locally; CI soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical Electron journey reproduced screenshot-like numeric mouse-report residue and a real SGR mouse packet reaching the shell before the focused action. With only the renderer's host-proven reset disabled, normal cleanup failed in normal-buffer/any-event mouse state and SIGKILL failed in alternate-buffer/any-event mouse state. Restoring the action made both cases green." + }, + "performanceBudget": { + "required": true, + "evidence": "No reveal timer, polling, periodic scan, broad session inventory, or renderer remount is added, and snapshots perform no writes. The stream pauses only for an unclean alternate-screen death (never for clean TUI exits or ordinary output), bounded by a 750 ms bail timer and a 256 KiB queue cap that both flush unmodified. The event-triggered fresh host inspection measured 30 local macOS samples at 84.6 ms median, 103.7 ms p95, and 124.5 ms maximum; the paired runtime asks the daemon for its settled verdict instead of running a second inspection." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the real journey on Linux and Windows, including ConPTY and WSL.", + "Add headed and headless paired-runtime journey evidence.", + "Keep missing-proof mixed-version behavior identical to the incumbent client path." + ], + "knownGaps": [ + "A zero-width injected reset does not advance outputSequence, so the mobile renderer-snapshot stability oracle cannot detect a reset landing inside its serialize round-trip; the resulting tear is transient and self-heals on the next repaint (same exposure as the pre-existing device-attributes release emission).", + "Bytes the dying command printed before its OSC 133;D (pre-boundary output into the still-active alternate screen, e.g. a final panic line) are discarded with the dead frame; only post-boundary shell output is preserved.", + "Direct local provider and degraded (daemon-less) sessions get ownership proof and the renderer-side reset, but no in-stream normalization — pre-reveal prompt output is not preserved there.", + "Visible Electron evidence is macOS-only.", + "Direct SSH intentionally fails closed to current-main behavior because foreground proof would inherit transport timeout and enumeration ambiguity.", + "WSL and Windows ConPTY process-membership behavior has no physical-host proof yet.", + "Any stopped (Ctrl-Z) descendant in the shell's tree refutes the proof, so a suspended unrelated job silently disables recovery for that pane (fails closed to incumbent behavior).", + "Attach is deliberately settle-free: a viewer connecting inside an in-flight proof (~100ms typical) receives the pre-reset snapshot as its first frame and self-heals when the in-stream reset arrives; snapshot RPC and remote serialize paths still inherit a bounded (750ms) settle wait while a proof is in flight.", + "Paired headed and headless remote-runtime topologies have not yet run the full pointer-input oracle." + ], + "demotionRule": "Keep experimental or demote if missing ownership proof changes incumbent behavior, a live TUI loses mouse/focus modes, foreground inspection enters ordinary output/reveal paths, or the focused Electron journey flakes without an identified product or harness fault." + }, { "id": "terminal-session.layout-pty-ownership", "title": "Restored terminal layouts retain one renderer owner per PTY", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 0f935f89aca..d604b83bf97 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 29m + + downloads: 30m @@ -15,7 +15,7 @@ downloads downloads - 29m - 29m + 30m + 30m diff --git a/mobile/patches/react-native@0.83.9.patch b/mobile/patches/react-native@0.83.9.patch index 102b052746b..9bda1b252fa 100644 --- a/mobile/patches/react-native@0.83.9.patch +++ b/mobile/patches/react-native@0.83.9.patch @@ -46,3 +46,15 @@ index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..c9d34f8b42644800b5c85e5be45b6ebf AttributedString::Range selectionRange; // ScrollView-like metrics Size contentSize; +diff --git a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +index 79ee7ffe43..956f189a1c 100644 +--- a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm ++++ b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +@@ -136,6 +136,7 @@ using namespace facebook::react; + - (void)prepareForRecycle + { + [super prepareForRecycle]; + _textView.state = nullptr; ++ _textView.layoutMetrics = EmptyLayoutMetrics; + _accessibilityProvider = nil; + } diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 56e64e1d442..01092f2acc9 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: patchedDependencies: react-native@0.83.9: - hash: a6502e7d63769bf1fd639cbbaab1cc0b8aebef31d22048e8f2186fb399d0c16e + hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d path: patches/react-native@0.83.9.patch importers: diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts index 227488a56df..a94104835a9 100644 --- a/mobile/src/session/mobile-native-chat-message-text.ts +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -21,3 +21,28 @@ export function clampFontScale(scale: number): number { } return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale)) } + +/** Granularity a pinch is allowed to commit at. */ +export const FONT_SCALE_STEP = 0.05 + +/** Snap a proposed font scale to the nearest committed step. + * + * Why quantize: the chat's `renderItem` closes over `fontScale`, so every + * distinct value re-renders and re-measures every message in the list. A raw + * pinch proposes a new scale on each gesture frame, so a single zoom drives + * hundreds of whole-list re-measures — and because the pinch is composed + * `Simultaneous` with the list's own scroll, an incidental second finger during + * a scroll starts that storm at scales indistinguishable from 1. Those + * re-measures run while rows are mounting and unmounting, which is when a + * recycled iOS paragraph view can repaint with the previous row's content + * frame and silently drop the tail of a long message. Snapping to steps keeps a + * full-range pinch to at most 20 commits and makes a near-neutral pinch commit + * nothing at all. */ +export function quantizeFontScale(scale: number): number { + if (Number.isNaN(scale)) { + return 1 + } + const stepped = Math.round(clampFontScale(scale) / FONT_SCALE_STEP) * FONT_SCALE_STEP + // Re-clamp: rounding can push the outermost step past the bound. + return clampFontScale(Number(stepped.toFixed(2))) +} diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts new file mode 100644 index 00000000000..8f34b6da9d5 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts @@ -0,0 +1,91 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FONT_SCALE_STEP, quantizeFontScale } from './mobile-native-chat-message-text' + +const handlers: { start?: () => void; update?: (e: { scale: number }) => void } = {} + +vi.mock('react-native-gesture-handler', () => { + const pinch = { + runOnJS: () => pinch, + onStart: (cb: () => void) => { + handlers.start = cb + return pinch + }, + onUpdate: (cb: (e: { scale: number }) => void) => { + handlers.update = cb + return pinch + } + } + return { + Gesture: { Simultaneous: (...g: unknown[]) => g, Native: () => ({}), Pinch: () => pinch } + } +}) + +import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' + +describe('quantizeFontScale', () => { + it('snaps to the commit step and stays inside the supported range', () => { + expect(quantizeFontScale(1)).toBe(1) + expect(quantizeFontScale(1.01)).toBe(1) + expect(quantizeFontScale(1.024)).toBe(1) + expect(quantizeFontScale(1.03)).toBe(1.05) + expect(quantizeFontScale(5)).toBe(1.8) + expect(quantizeFontScale(0.1)).toBe(0.8) + expect(quantizeFontScale(Number.NaN)).toBe(1) + }) + + it('collapses a full-range pinch to at most one commit per step', () => { + const committed = new Set() + for (let i = 0; i <= 600; i++) { + committed.add(quantizeFontScale(0.7 + i * 0.002)) + } + expect(committed.size).toBeLessThanOrEqual(Math.round(1 / FONT_SCALE_STEP) + 1) + }) +}) + +describe('useMobileNativeChatPinchGesture', () => { + let renderer: { unmount: () => void } | null = null + let latest = 1 + + function Probe(): null { + latest = useMobileNativeChatPinchGesture().fontScale + return null + } + + beforeEach(() => { + latest = 1 + act(() => { + renderer = create(createElement(Probe)) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + // Why this matters: `renderItem` closes over `fontScale`, so each committed + // value re-measures every message in the list. Committing raw gesture scales + // drives hundreds of whole-list re-measures per pinch, and a stray second + // finger during an ordinary scroll starts that storm at scales the user cannot + // even see. Those re-measures are what expose the recycled-paragraph repaint + // that silently drops the tail of a long message. + it('commits nothing for gesture noise the user cannot see', () => { + act(() => handlers.start?.()) + for (const scale of [1.001, 0.995, 1.008, 1.02, 0.982]) { + act(() => handlers.update?.({ scale })) + } + expect(latest).toBe(1) + }) + + it('commits a real pinch, on the step grid', () => { + act(() => handlers.start?.()) + act(() => handlers.update?.({ scale: 1.34 })) + expect(latest).toBe(1.35) + act(() => handlers.update?.({ scale: 1.37 })) + expect(latest).toBe(1.35) + act(() => handlers.update?.({ scale: 1.42 })) + expect(latest).toBe(1.4) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts index 3b3cdb2e8a7..1aebada4f4d 100644 --- a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts @@ -1,7 +1,7 @@ import { useMemo, useRef, useState } from 'react' import { Gesture } from 'react-native-gesture-handler' import type { ComposedGesture } from 'react-native-gesture-handler' -import { clampFontScale } from './mobile-native-chat-message-text' +import { quantizeFontScale } from './mobile-native-chat-message-text' /** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase` * anchors the live gesture so successive pinches compound rather than reset. */ @@ -14,8 +14,8 @@ export function useMobileNativeChatPinchGesture(): { fontScaleRef.current = fontScale const pinchBase = useRef(1) // Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so - // they can touch React refs/state and clampFontScale directly — accessing those - // from the UI-thread worklet crashes the app. + // they can touch React refs/state and quantizeFontScale directly — accessing + // those from the UI-thread worklet crashes the app. // Compose the pinch with the list's native scroll as Simultaneous so a // two-finger pinch is recognized even while the scroll view is active — // otherwise the scroll grabs the gesture first and the pinch never fires. @@ -29,7 +29,7 @@ export function useMobileNativeChatPinchGesture(): { pinchBase.current = fontScaleRef.current }) .onUpdate((e) => { - setFontScale(clampFontScale(pinchBase.current * e.scale)) + setFontScale(quantizeFontScale(pinchBase.current * e.scale)) }) ), [] diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 441ec609624..b94bcc2435d 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -15,8 +15,7 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn() + }) }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -33,35 +32,13 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['codex', 'claude', 'flush']) }) - it('runs WSL Codex preservation through the runtime service', async () => { - const syncForCurrentSelection = vi.fn() - const syncActiveWslSelectionsBeforeRestart = vi.fn() - - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection, - syncActiveWslSelectionsBeforeRestart - }, - store: { - flushPendingOrThrowAsync: vi.fn() - } - }) - - expect(syncForCurrentSelection).toHaveBeenCalledTimes(1) - expect(syncForCurrentSelection).toHaveBeenNthCalledWith(1) - expect(syncActiveWslSelectionsBeforeRestart).toHaveBeenCalledTimes(1) - }) - - it('runs Claude preservation before WSL Codex preservation', async () => { + it('runs Claude preservation after Codex and before the store flush', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex-host') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn(() => { - calls.push('codex-wsl') }) }, claudeRuntimeAuth: { @@ -76,26 +53,80 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex-host', 'claude', 'codex-wsl', 'flush']) + expect(calls).toEqual(['codex-host', 'claude', 'flush']) }) - it('continues after WSL Codex preservation fails', async () => { + it('drains retained WSL Codex auth before flushing the store', async () => { + const calls: string[] = [] + + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection: vi.fn(() => { + calls.push('codex-host') + }), + syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { + calls.push('codex-wsl') + }) + }, + store: { + flushPendingOrThrowAsync: vi.fn(async () => { + calls.push('flush') + }) + } + }) + + expect(calls).toEqual(['codex-host', 'codex-wsl', 'flush']) + }) + + it('does not release restart while the bounded WSL drain is still running', async () => { + vi.useFakeTimers() + let finishWslDrain!: () => void + let settled = false + const flushPendingOrThrowAsync = vi.fn() + + const preservation = preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection: vi.fn(), + syncActiveWslSelectionsBeforeRestart: vi.fn( + () => + new Promise((resolve) => { + finishWslDrain = resolve + }) + ) + }, + store: { flushPendingOrThrowAsync } + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(1) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + + finishWslDrain() + await preservation + expect(settled).toBe(true) + }) + + it('continues after the bounded WSL drain fails without logging secrets', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const flushPendingOrThrowAsync = vi.fn() await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(), - syncActiveWslSelectionsBeforeRestart: vi.fn(() => { + syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { throw new Error('wsl-token-secret') }) }, - store: { - flushPendingOrThrowAsync - } + store: { flushPendingOrThrowAsync } }) expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[agent-auth-restart] Codex auth preservation failed (Error); continuing restart/update' + ) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) @@ -116,8 +147,7 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { throw new Error('codex-token-secret') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn() + }) }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -170,6 +200,36 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) }) + it('gives store persistence its own timeout after auth preservation expires', async () => { + vi.useFakeTimers() + let finishStoreFlush!: () => void + let settled = false + + const preservation = preserveAgentAuthBeforeRestart({ + claudeRuntimeAuth: { + syncForCurrentSelection: vi.fn(() => new Promise(() => {})) + }, + store: { + flushPendingOrThrowAsync: vi.fn( + () => + new Promise((resolve) => { + finishStoreFlush = resolve + }) + ) + } + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(false) + + finishStoreFlush() + await preservation + expect(settled).toBe(true) + }) + it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index eaae45f331b..e351bfee334 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -4,10 +4,8 @@ import type { Store } from './persistence' const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 -type CodexRuntimeAuthSync = Pick< - CodexRuntimeHomeService, - 'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart' -> +type CodexRuntimeAuthSync = Pick & + Partial> type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick @@ -27,35 +25,26 @@ export async function preserveAgentAuthBeforeRestart({ claudeRuntimeAuth, store }: AgentAuthRestartPreservationOptions): Promise { - const startedAt = Date.now() - runCodexPreservationStep(codexRuntimeHome) + // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. + const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) - const remainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) - if (claudeRuntimeAuth && remainingMs > 0) { + if (claudeRuntimeAuth) { await runWithinLifecycleTimeout( 'Claude auth preservation', () => claudeRuntimeAuth.syncForCurrentSelection(), - remainingMs - ) - } else if (claudeRuntimeAuth) { - logStepTimeout('Claude auth preservation', 0) - } - - if (codexRuntimeHome && Date.now() - startedAt < AUTH_PRESERVATION_TIMEOUT_MS) { - runWslCodexPreservationStep(codexRuntimeHome) - } else if (codexRuntimeHome) { - logStepTimeout('Codex auth preservation', 0) - } - - if (store) { - const storeRemainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) - await runWithinLifecycleTimeout( - 'Store persistence', - () => store.flushPendingOrThrowAsync(), - storeRemainingMs + AUTH_PRESERVATION_TIMEOUT_MS ) } + + const storePreservation = store + ? runWithinLifecycleTimeout( + 'Store persistence', + () => store.flushPendingOrThrowAsync(), + AUTH_PRESERVATION_TIMEOUT_MS + ) + : Promise.resolve() + await Promise.all([wslCodexPreservation, storePreservation]) } function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null | undefined): void { @@ -66,11 +55,11 @@ function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null } } -function runWslCodexPreservationStep( +async function runWslCodexPreservationStep( codexRuntimeHome: CodexRuntimeAuthSync | null | undefined -): void { +): Promise { try { - codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart() + await codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart?.() } catch (error) { logStepFailure('Codex auth preservation', error) } @@ -89,7 +78,7 @@ async function runWithinLifecycleTimeout( }) // Why: this timeout only releases the restart/update path. It does not - // cancel a sync that already started, and Codex sync is synchronous today. + // cancel a sync that already started. const timeoutResult = new Promise<'timeout'>((resolve) => { timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts new file mode 100644 index 00000000000..44ce6a6e00f --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts @@ -0,0 +1,170 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { _internals } from './legacy-wsl-runtime-auth-drain' + +const isWindows = process.platform === 'win32' + +const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' +const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n' +const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' +// Codex truncates before it writes, so a read landing mid-rotation sees this. +const TORN_AUTH = '{"tokens":{"exp' +const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n' +const TORN_CREDENTIALS = '{"server":' + +function sha256(contents: string): string { + return createHash('sha256').update(contents).digest('hex') +} + +/** + * Runs the real guest script under `sh`, with `sha256sum` shimmed so a chosen + * hash call can rewrite the source underneath the script. That is the only way + * to land Codex's in-place rotation inside the window the script itself opens. + */ +function runApplyScript( + options: { + rewriteSourceAfterHashCall?: number + rewriteBytes?: string + rewriteTarget?: 'source-auth' | 'source-credentials' | 'target-auth' + sourceCredentials?: string + } = {} +): { + legacyAuth: string + status: number + targetAuth: string + targetCredentials: string | null +} { + const root = mkdtempSync(join(tmpdir(), 'orca-drain-apply-')) + const legacyHome = join(root, 'legacy') + const targetHome = join(root, 'account') + const binDir = join(root, 'bin') + for (const dir of [legacyHome, targetHome, binDir]) { + mkdirSync(dir, { recursive: true }) + } + const legacyAuthPath = join(legacyHome, 'auth.json') + const targetAuthPath = join(targetHome, 'auth.json') + const legacyCredentialsPath = join(legacyHome, '.credentials.json') + const targetCredentialsPath = join(targetHome, '.credentials.json') + writeFileSync(legacyAuthPath, SOURCE_AUTH) + writeFileSync(targetAuthPath, TARGET_AUTH) + if (options.sourceCredentials !== undefined) { + writeFileSync(legacyCredentialsPath, options.sourceCredentials) + } + + const counterPath = join(root, 'hash-calls') + writeFileSync(counterPath, '0') + const shimPath = join(binDir, 'sha256sum') + writeFileSync( + shimPath, + `#!/usr/bin/env node +const { createHash } = require('node:crypto') +const fs = require('node:fs') +const file = process.argv[process.argv.length - 1] +process.stdout.write( + createHash('sha256').update(fs.readFileSync(file)).digest('hex') + ' ' + file + '\\n' +) +const calls = Number(fs.readFileSync(process.env.HASH_COUNTER, 'utf8')) + 1 +fs.writeFileSync(process.env.HASH_COUNTER, String(calls)) +if (process.env.REWRITE_AFTER && calls === Number(process.env.REWRITE_AFTER)) { + fs.writeFileSync(process.env.REWRITE_TARGET, process.env.REWRITE_BYTES) +} +` + ) + chmodSync(shimPath, 0o755) + + let status = 0 + try { + execFileSync( + '/bin/sh', + [ + '-c', + _internals.applyLegacyAuthScript, + 'sh', + legacyHome, + join(root, 'absent-active-home'), + join(root, 'absent-marker.json'), + targetHome, + sha256(SOURCE_AUTH), + sha256(TARGET_AUTH), + '1', + '0', + options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials) + ], + { + encoding: 'utf8', + env: { + ...process.env, + HASH_COUNTER: counterPath, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + REWRITE_AFTER: options.rewriteSourceAfterHashCall + ? String(options.rewriteSourceAfterHashCall) + : '', + REWRITE_BYTES: options.rewriteBytes ?? TORN_AUTH, + REWRITE_TARGET: + options.rewriteTarget === 'source-credentials' + ? legacyCredentialsPath + : options.rewriteTarget === 'target-auth' + ? targetAuthPath + : legacyAuthPath + }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 20_000 + } + ) + } catch (error) { + status = (error as { status?: number }).status ?? -1 + } + return { + legacyAuth: readFileSync(legacyAuthPath, 'utf8'), + status, + targetAuth: readFileSync(targetAuthPath, 'utf8'), + targetCredentials: existsSync(targetCredentialsPath) + ? readFileSync(targetCredentialsPath, 'utf8') + : null + } +} + +describe.skipIf(isWindows)('legacy WSL auth drain apply script', () => { + it('promotes the validated source into the account home', () => { + const outcome = runApplyScript() + expect(outcome.targetAuth).toBe(SOURCE_AUTH) + }) + + it('leaves the legacy home untouched while promoting', () => { + // One-directional: the promote step must never write back to the old home. + expect(runApplyScript().legacyAuth).toBe(SOURCE_AUTH) + }) + + it('refuses torn bytes and leaves the account home intact', () => { + // Hash call 1 is the source pre-check; rotating right after it means `cp` + // reads bytes freshness never judged. Pre-guard, those reached the target. + const outcome = runApplyScript({ rewriteSourceAfterHashCall: 1 }) + expect(outcome.status).toBe(42) + expect(outcome.targetAuth).toBe(TARGET_AUTH) + }) + + it('refuses MCP credentials that changed after host validation', () => { + const outcome = runApplyScript({ + rewriteSourceAfterHashCall: 2, + rewriteBytes: TORN_CREDENTIALS, + rewriteTarget: 'source-credentials', + sourceCredentials: SOURCE_CREDENTIALS + }) + expect(outcome.status).toBe(43) + expect(outcome.targetCredentials).toBeNull() + }) + + it('does not overwrite auth changed after the destination hash check', () => { + const outcome = runApplyScript({ + rewriteBytes: NEWER_AUTH, + rewriteSourceAfterHashCall: 4, + rewriteTarget: 'target-auth' + }) + expect(outcome.status).toBe(39) + expect(outcome.targetAuth).toBe(NEWER_AUTH) + }) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts new file mode 100644 index 00000000000..0c0caa1b669 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runWslProcessMock } = vi.hoisted(() => ({ + runWslProcessMock: vi.fn() +})) + +vi.mock('../wsl/wsl-runner', () => ({ + runWslProcess: runWslProcessMock +})) + +import { + _internals, + drainLegacyWslRuntimeAuth, + startLegacyWslRuntimeAuthDrain +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths } from './wsl-codex-auth-batch-reader' + +const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' +const STALE_AUTH = '{"tokens":{"expires_at":1000}}\n' +const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' + +function inspection(auth: string, credentials?: string): string { + return [ + Buffer.from(auth).toString('base64'), + credentials === undefined ? 'missing' : 'present', + credentials === undefined ? '' : Buffer.from(credentials).toString('base64') + ].join('\n') +} + +function result(code: number, stdout = '') { + return { + code, + stdout, + stderr: '', + timedOut: false, + environmentResolved: true + } +} + +describe('legacy WSL runtime auth drain', () => { + beforeEach(() => { + runWslProcessMock.mockReset() + _internals.resetDrainQueue() + }) + + it('promotes fresher auth guest-side while a legacy pane remains', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + .mockResolvedValueOnce(result(0)) + const resolveDestination = vi.fn(() => ({ + authContents: STALE_AUTH, + linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home' + })) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination + }) + + expect(resolveDestination).toHaveBeenCalledWith(SOURCE_AUTH) + expect(runWslProcessMock).toHaveBeenCalledTimes(2) + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(3)).toEqual([ + '/home/alice/.local/share/orca/codex-accounts/account-1/home', + expect.any(String), + expect.any(String), + '1', + '0', + 'missing' + ]) + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('readlink -f') + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('source_credentials=') + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('chmod 600') + }) + + it('does not write when freshness cannot be proven', async () => { + runWslProcessMock.mockResolvedValueOnce(result(0, inspection('{"tokens":{}}\n'))) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: '{"tokens":{}}\n', + linuxHomePath: '/home/alice/.codex' + }) + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('refuses a source with no unique destination', async () => { + runWslProcessMock.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => null + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('retires stale legacy auth only after the last recorded pane exits', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + .mockResolvedValueOnce(result(0)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: NEWER_AUTH, + linuxHomePath: '/home/alice/.codex' + }) + }) + + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-3)).toEqual(['0', '1', 'missing']) + }) + + it('keeps an absent source retryable while a legacy pane remains', async () => { + runWslProcessMock.mockResolvedValueOnce(result(21)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination: vi.fn() + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('does nothing after the guest-side completion marker is present', async () => { + runWslProcessMock.mockResolvedValueOnce(result(20)) + const resolveDestination = vi.fn() + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination + }) + + expect(resolveDestination).not.toHaveBeenCalled() + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('marks an absent source complete after every legacy pane exits', async () => { + runWslProcessMock.mockResolvedValueOnce(result(21)).mockResolvedValueOnce(result(0)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: vi.fn() + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(2) + expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([ + '/home/alice/.local/share/orca/codex-runtime-home/home', + '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home', + '/home/alice/.local/share/orca/codex-runtime-home/direct-home-auth-drain-v1.json' + ]) + }) + + it('coalesces concurrent drain triggers instead of queueing every poll', async () => { + runWslProcessMock.mockImplementation(() => new Promise(() => {})) + const options = { + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination: () => null + } + + startLegacyWslRuntimeAuthDrain(options) + startLegacyWslRuntimeAuthDrain(options) + await Promise.resolve() + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('reads every candidate home in one bounded guest process', async () => { + runWslProcessMock.mockResolvedValueOnce( + result( + 0, + [`present:${Buffer.from(SOURCE_AUTH).toString('base64')}`, 'missing', 'unreadable'].join( + '\n' + ) + ) + ) + + await expect( + readWslCodexAuths('Ubuntu', ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad']) + ).resolves.toEqual([ + { kind: 'present', contents: SOURCE_AUTH }, + { kind: 'missing' }, + { kind: 'unreadable' } + ]) + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + expect(runWslProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'], + maxOutputBytes: 2 * 1024 * 1024, + timeoutMs: 5_000 + }) + ) + }) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts new file mode 100644 index 00000000000..bde5d4c9ebc --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -0,0 +1,319 @@ +import { createHash } from 'node:crypto' +import { posix as pathPosix } from 'node:path' +import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' +import { runWslProcess } from '../wsl/wsl-runner' +import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity' +import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader' + +const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json' +const MARKER_PRESENT_EXIT = 20 +const SOURCE_AUTH_ABSENT_EXIT = 21 + +export type LegacyWslRuntimeAuthDestination = { + authContents: string + linuxHomePath: string +} + +type LegacyWslRuntimeInspection = { + authContents: string + credentials: { kind: 'missing' } | { kind: 'present'; contents: string } +} + +type LegacyWslRuntimeAuthDrainOptions = { + distro: string + guestHomeLinuxPath: string + legacyPanePresent: boolean + resolveDestination: ( + runtimeAuthContents: string + ) => LegacyWslRuntimeAuthDestination | null | Promise +} + +const drainQueueByDistro = new Map>() +const completedDistroKeys = new Set() + +export function startLegacyWslRuntimeAuthDrain( + options: LegacyWslRuntimeAuthDrainOptions +): Promise { + const key = options.distro.trim().toLowerCase() + if (completedDistroKeys.has(key)) { + return Promise.resolve() + } + // Coalesce launch/rate-limit callers while a drain is in flight. Queuing a + // new pass for every poll can otherwise build an unbounded promise chain + // while a legacy pane keeps the migration pending. + const inFlight = drainQueueByDistro.get(key) + if (inFlight) { + return inFlight + } + const next = drainLegacyWslRuntimeAuth(options) + .then((status) => { + if (status === 'complete') { + completedDistroKeys.add(key) + } + }) + .catch((error) => { + console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error) + }) + drainQueueByDistro.set(key, next) + void next.finally(() => { + if (drainQueueByDistro.get(key) === next) { + drainQueueByDistro.delete(key) + } + }) + return next +} + +export async function drainLegacyWslRuntimeAuth( + options: LegacyWslRuntimeAuthDrainOptions +): Promise<'complete' | 'pending'> { + const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath) + const inspection = await runWslProcess({ + distro: options.distro, + loginPath: 'none', + script: INSPECT_LEGACY_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (inspection.code === MARKER_PRESENT_EXIT) { + return 'complete' + } + if (inspection.code === SOURCE_AUTH_ABSENT_EXIT) { + if (!options.legacyPanePresent) { + await finalizeAbsentLegacyAuth(options.distro, paths) + return 'complete' + } + return 'pending' + } + assertSuccessfulDrainStep('inspect', inspection) + + const inspected = parseLegacyRuntimeInspection(inspection.stdout) + if (!inspected) { + return 'pending' + } + const destination = await options.resolveDestination(inspected.authContents) + if (!destination) { + return 'pending' + } + const freshness = compareCodexAuthFreshness(inspected.authContents, destination.authContents) + if (freshness === null) { + return 'pending' + } + const promoteAuth = codexAuthIsFresher(inspected.authContents, destination.authContents) + const result = await runWslProcess({ + distro: options.distro, + loginPath: 'none', + script: APPLY_LEGACY_AUTH_SCRIPT, + args: [ + paths.runtimeHome, + paths.activeHome, + paths.marker, + destination.linuxHomePath, + sha256(inspected.authContents), + sha256(destination.authContents), + promoteAuth ? '1' : '0', + options.legacyPanePresent ? '0' : '1', + inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing' + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024 + }) + assertSuccessfulDrainStep('apply', result) + return options.legacyPanePresent ? 'pending' : 'complete' +} + +function parseLegacyRuntimeInspection(stdout: string): LegacyWslRuntimeInspection | null { + const [authBase64, credentialsKind, credentialsBase64] = stdout.split('\n') + const authContents = decodeWslBase64Payload(authBase64 ?? '') + if (authContents === null) { + return null + } + if (credentialsKind === 'missing') { + return { authContents, credentials: { kind: 'missing' } } + } + if (credentialsKind !== 'present') { + return null + } + const credentialsContents = decodeWslBase64Payload(credentialsBase64 ?? '') + if (!credentialsContents || !isJsonObject(credentialsContents)) { + return null + } + return { authContents, credentials: { kind: 'present', contents: credentialsContents } } +} + +function isJsonObject(contents: string): boolean { + try { + const value = JSON.parse(contents) as unknown + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + } catch { + return false + } +} + +function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): { + activeHome: string + marker: string + runtimeHome: string +} { + const runtimeHome = pathPosix.join(guestHomeLinuxPath, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) + const runtimeRoot = pathPosix.dirname(runtimeHome) + return { + activeHome: pathPosix.join(runtimeRoot, 'active', 'wsl', 'home'), + marker: pathPosix.join(runtimeRoot, DRAIN_MARKER_NAME), + runtimeHome + } +} + +async function finalizeAbsentLegacyAuth( + distro: string, + paths: ReturnType +): Promise { + const result = await runWslProcess({ + distro, + loginPath: 'none', + script: FINALIZE_ABSENT_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024 + }) + assertSuccessfulDrainStep('finalize', result) +} + +function assertSuccessfulDrainStep( + step: string, + result: { code: number | null; stderr: string; timedOut: boolean } +): void { + if (result.code === 0 && !result.timedOut) { + return + } + const detail = result.stderr.trim() + throw new Error( + `Legacy WSL auth drain ${step} failed (${result.timedOut ? 'timeout' : `exit ${result.code}`})${detail ? `: ${detail}` : ''}` + ) +} + +function sha256(contents: string): string { + return createHash('sha256').update(contents).digest('hex') +} + +const RESOLVE_LEGACY_HOME_SCRIPT = ` +legacy_home="$1" +legacy_home_resolved=0 +if [ -e "$1" ] || [ -L "$1" ]; then + legacy_home=$(readlink -f -- "$1") || exit 30 + legacy_home_resolved=1 +fi +if [ -e "$2" ] || [ -L "$2" ]; then + active_home=$(readlink -f -- "$2") || exit 31 + if [ "$legacy_home_resolved" = 1 ]; then + [ "$active_home" = "$legacy_home" ] || exit 32 + else + legacy_home="$active_home" + fi +fi +` + +const INSPECT_LEGACY_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit ${MARKER_PRESENT_EXIT} +${RESOLVE_LEGACY_HOME_SCRIPT} +source_auth="$legacy_home/auth.json" +[ -f "$source_auth" ] || exit ${SOURCE_AUTH_ABSENT_EXIT} +encode_file() { + encoded=$(base64 < "$1") || return 1 + printf '%s' "$encoded" | tr -d '\n' +} +encode_file "$source_auth" +printf '\n' +source_credentials="$legacy_home/.credentials.json" +if [ -f "$source_credentials" ]; then + printf 'present\n' + encode_file "$source_credentials" + printf '\n' +elif [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ]; then + printf 'missing\n\n' +else + exit 44 +fi +` + +const APPLY_LEGACY_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit 0 +${RESOLVE_LEGACY_HOME_SCRIPT} +target_home=$(readlink -f -- "$4") || exit 33 +[ "$legacy_home" != "$target_home" ] || exit 34 +source_auth="$legacy_home/auth.json" +target_auth="$target_home/auth.json" +[ -f "$source_auth" ] || exit 35 +[ -f "$target_auth" ] || exit 36 +hash_file() { sha256sum -- "$1" | cut -d ' ' -f 1; } +[ "$(hash_file "$source_auth")" = "$5" ] || exit 37 +[ "$(hash_file "$target_auth")" = "$6" ] || exit 38 +umask 077 +temporary_auth="$target_auth.orca-drain-$$" +temporary_credentials="$target_home/.credentials.json.orca-drain-$$" +temporary_previous_auth="$target_auth.orca-drain-previous-$$" +temporary_marker="$3.orca-drain-$$" +cleanup() { rm -f -- "$temporary_auth" "$temporary_credentials" "$temporary_previous_auth" "$temporary_marker"; } +trap cleanup EXIT HUP INT TERM +source_credentials="$legacy_home/.credentials.json" +target_credentials="$target_home/.credentials.json" +if [ -f "$source_credentials" ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then + [ "$9" != missing ] || exit 43 + [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 + cp -- "$source_credentials" "$temporary_credentials" + chmod 600 "$temporary_credentials" + [ "$(hash_file "$temporary_credentials")" = "$9" ] || exit 43 + [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 + mv -n -- "$temporary_credentials" "$target_credentials" +elif [ "$9" = missing ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then + [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ] || exit 43 +fi +if [ "$7" = 1 ]; then + cp -- "$source_auth" "$temporary_auth" + chmod 600 "$temporary_auth" + # Codex rewrites auth.json in place, so this copy is a second read: verify the + # bytes being promoted, not the ones freshness was judged on. + [ "$(hash_file "$temporary_auth")" = "$5" ] || exit 42 + [ "$(hash_file "$target_auth")" = "$6" ] || exit 39 + # The hard link keeps the destination inode observable without creating a + # missing-path crash window. In-place writers update both names. + ln -- "$target_auth" "$temporary_previous_auth" + [ "$(hash_file "$temporary_previous_auth")" = "$6" ] || exit 39 + mv -f -- "$temporary_auth" "$target_auth" + if [ "$(hash_file "$temporary_previous_auth")" != "$6" ]; then + mv -f -- "$temporary_previous_auth" "$target_auth" + exit 39 + fi + rm -- "$temporary_previous_auth" +fi +if [ "$8" = 1 ]; then + [ "$(hash_file "$source_auth")" = "$5" ] || exit 40 + rm -- "$source_auth" + printf '%s\n' '{"completed":true}' > "$temporary_marker" + chmod 600 "$temporary_marker" + mv -f -- "$temporary_marker" "$3" +fi +` + +const FINALIZE_ABSENT_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit 0 +${RESOLVE_LEGACY_HOME_SCRIPT} +[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41 +umask 077 +temporary_marker="$3.orca-drain-$$" +trap 'rm -f -- "$temporary_marker"' EXIT HUP INT TERM +printf '%s\n' '{"completed":true}' > "$temporary_marker" +chmod 600 "$temporary_marker" +mv -f -- "$temporary_marker" "$3" +` + +export const _internals = { + applyLegacyAuthScript: APPLY_LEGACY_AUTH_SCRIPT, + resetDrainQueue: (): void => { + drainQueueByDistro.clear() + completedDistroKeys.clear() + } +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index cf3a0105d63..7975c7172ad 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,5 +1,4 @@ /* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ -import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { appendFileSync, copyFileSync, @@ -18,7 +17,6 @@ import { unlinkSync } from 'node:fs' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { execFileSync } from 'node:child_process' import { dirname, extname, @@ -33,7 +31,6 @@ import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/managed-account-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' -import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { recoverInterruptedGuardedFileOperation, removeFileAtomicallyIfUnchanged, @@ -55,16 +52,13 @@ import { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' -import { - prepareSystemConfigForFreshRuntimeMirror, - syncSystemConfigIntoManagedCodexHome -} from '../codex/codex-config-mirror' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { + getCodexSelectionLaneKey, getWslSelectionKey, getSelectedCodexAccountIdForTarget, normalizeCodexRuntimeSelection, - setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' @@ -91,10 +85,16 @@ import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' import { getCodexPaneAccount, + hasRecordedLegacyWslCodexPane, hasRecordedLegacySharedCodexPane, type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { + startLegacyWslRuntimeAuthDrain, + type LegacyWslRuntimeAuthDestination +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' type CodexSystemDefaultSnapshot = { authJson: string | null @@ -130,7 +130,6 @@ type CodexRuntimeLogoutMarkerStatus = | { kind: 'applies' } | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } -type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' type CodexReadBackMatch = | { kind: 'matched' @@ -195,11 +194,6 @@ export class CodexRuntimeHomeService { private lastSyncedAccountId: string | null = null // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). private lastWrittenAuthJson: string | null = null - // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. - private readonly lastWrittenWslAuthJsonByDistro = new Map() - private readonly lastSyncedWslAccountIdByDistro = new Map() - private readonly wslRuntimeHomePathByDistro = new Map() - private skipNextReadBackForAccountId: string | null = null // Why: a managed host account refreshes auth in its own home. Remember that // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false @@ -244,11 +238,11 @@ export class CodexRuntimeHomeService { ): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) - this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) - const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) - this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) - return runtimeHomePath + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + this.startLegacyWslAuthDrain(wslTarget) + this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, homePath) + this.startWslSessionBridgeForLaunch(wslTarget, homePath) + return homePath } const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { @@ -724,26 +718,6 @@ export class CodexRuntimeHomeService { syncLegacySharedCodexConfigForRetainedPanes() } - syncActiveWslSelectionsBeforeRestart(): void { - if (process.platform !== 'win32') { - return - } - - const settings = this.store.getSettings() - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey) - } - } - private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null @@ -790,10 +764,9 @@ export class CodexRuntimeHomeService { prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) return { kind: 'ready', - codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) } } const selfContainedAccount = this.getSelfContainedManagedHostAccount() @@ -833,7 +806,7 @@ export class CodexRuntimeHomeService { launchEnv?: NodeJS.ProcessEnv ): void { if (target?.runtime === 'wsl') { - this.syncWslRuntimeForCurrentSelection(target) + this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) return } @@ -880,7 +853,6 @@ export class CodexRuntimeHomeService { // distro-local runtime home, so the host mirror only drops its baseline. this.lastSyncedAccountId = null this.lastWrittenAuthJson = null - this.skipNextReadBackForAccountId = null return } if (normalizeCodexRuntimeSelection(settings).host) { @@ -922,71 +894,13 @@ export class CodexRuntimeHomeService { } } - // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. + // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. clearLastWrittenAuthJson( accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host ): void { if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null } - this.skipNextReadBackForAccountId = accountId - } - - private readBackRefreshedTokensFromPath( - runtimeAuthPath: string, - options: { - updateLastWrittenAuthJson: boolean - lastWrittenAuthJson?: string | null - setLastWrittenAuthJson?: (contents: string) => void - expectedAccountId?: string - } - ): CodexReadBackResult { - try { - if (!existsSync(runtimeAuthPath)) { - return 'unchanged' - } - - const lastWrittenAuthJson = - options.lastWrittenAuthJson === undefined - ? this.lastWrittenAuthJson - : options.lastWrittenAuthJson - const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { - return 'unchanged' - } - - const match = this.findManagedAccountForRuntimeAuth( - runtimeContents, - options.expectedAccountId - ) - if (match.kind !== 'matched') { - if (match.kind === 'ambiguous') { - console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') - } - return 'rejected' - } - // Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage. - if ( - lastWrittenAuthJson === null && - !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) - ) { - return 'rejected' - } - - writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) - if (options.updateLastWrittenAuthJson) { - if (options.setLastWrittenAuthJson) { - options.setLastWrittenAuthJson(runtimeContents) - } else { - this.lastWrittenAuthJson = runtimeContents - } - } - return 'persisted' - } catch (error) { - // Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle. - console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) - return 'rejected' - } } // Why: which ~/.codex bytes the mirror was seeded from, and whether the system @@ -1051,223 +965,152 @@ export class CodexRuntimeHomeService { } private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - const distro = target.wslDistro?.trim() - if (distro) { - const settings = this.store.getSettings() - const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) - if (selectedAccountId === null) { - // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. - return this.getWslSystemCodexHomePath(target) - } - const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) - if ( - cachedRuntimeHomePath && - this.lastSyncedWslAccountIdByDistro.has(distro) && - this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId - ) { - // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. - return cachedRuntimeHomePath - } - } - return this.syncWslRuntimeForCurrentSelection(target) + return this.getWslCodexHomePathForSelection(target) } - private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { - if (process.platform !== 'win32') { - return null - } - - const wslTarget = this.resolveWslDefaultTarget(target) + private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( + const account = this.getActiveAccount( settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, wslTarget) + getSelectedCodexAccountIdForTarget(settings, target) ) - const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() + if (account) { + const managedHome = parseWslUncPath(account.managedHomePath) + const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() + // Persisted selections can outlive an account's runtime metadata. Never + // hand a host home (or another distro's UNC home) to a WSL launch. + const accountDistro = account.wslDistro?.trim() + const distroMatches = + (!accountDistro || + !targetDistro || + accountDistro.toLowerCase() === targetDistro.toLowerCase()) && + (!managedHome || + !targetDistro || + managedHome.distro.toLowerCase() === targetDistro.toLowerCase()) + // Older persisted fixtures may not carry a UNC spelling, but the + // explicit WSL runtime marker still proves the lane; reject unmarked + // host paths while retaining those legacy WSL records. + if (distroMatches && (account.managedHomeRuntime === 'wsl' || managedHome)) { + return account.managedHomePath + } + } + return this.getWslSystemCodexHomePath(target) + } + + private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): Promise { + if (process.platform !== 'win32') { + return Promise.resolve() + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() if (!distro) { - return null + return Promise.resolve() } - - const runtimeHomePath = this.getWslRuntimeHomePath(distro) - if (!runtimeHomePath) { - return null + const guestHome = getWslHome(distro) + const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null + if (!parsedGuestHome) { + return Promise.resolve() } - this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath) - - mkdirSync(runtimeHomePath, { recursive: true }) - this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) - this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) - - const runtimeAuthPath = join(runtimeHomePath, 'auth.json') - const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null - if (previousWslAccountId) { - if (this.skipNextReadBackForAccountId === previousWslAccountId) { - this.skipNextReadBackForAccountId = null - } else { - const previousWslAccount = this.getActiveAccount( - settings.codexManagedAccounts, - previousWslAccountId - ) - if (previousWslAccount) { - this.readBackRefreshedTokensFromPath(runtimeAuthPath, { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: previousWslAccount.id - }) - } - } - } - - const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null - if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { - const activeAuth = readFileSync(activeAuthPath, 'utf-8') - this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) - return runtimeHomePath - } - if (activeAccount && activeAuthPath) { - console.warn( - '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' - ) - this.store.updateSettings({ - activeCodexManagedAccountId: settings.activeCodexManagedAccountId, - activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( - normalizeCodexRuntimeSelection(settings), - null, - wslTarget - ) - }) - } - - const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) - if (systemAuthPath && existsSync(systemAuthPath)) { - const systemAuth = readFileSync(systemAuthPath, 'utf-8') - const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null - const runtimeAuth = existsSync(runtimeAuthPath) - ? readFileSync(runtimeAuthPath, 'utf-8') - : null - if ( - runtimeAuth !== null && - runtimeAuth !== systemAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && - ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || - (mirroredSystemDefaultAuth === null && - this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) - ) { - // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. - this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenWslAuthJsonByDistro.set(distro, null) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - - private getWslRuntimeHomePath(distro: string): string | null { - const home = getWslHome(distro) - return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null - } - - private safeReadBackActiveWslAccountBeforeRestart( - account: CodexManagedAccount, - selectedDistroKey: string - ): void { + let legacyPanePresent: boolean try { - this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey) + legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) } catch (error) { - console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error) + console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) + return Promise.resolve() } - } - - private readBackActiveWslAccountBeforeRestart( - account: CodexManagedAccount, - selectedDistroKey: string - ): void { - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() - : selectedDistroKey.trim() || account.wslDistro?.trim() - if (!distro) { - return - } - - const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) - if (!runtimeHomePath) { - return - } - - this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: account.id + return startLegacyWslRuntimeAuthDrain({ + distro, + guestHomeLinuxPath: parsedGuestHome.linuxPath, + legacyPanePresent, + resolveDestination: (runtimeAuthContents) => + this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) }) } - private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { - try { - this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath) - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error) - } - } - - private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { - const runtimeWsl = parseWslUncPath(runtimeHomePath) - if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) { + /** Preserve refreshed auth from retained legacy WSL panes before restart. */ + async syncActiveWslSelectionsBeforeRestart(): Promise { + if (process.platform !== 'win32') { return } - const activeLinuxPath = runtimeWsl.linuxPath.replace( - /\/codex-runtime-home\/home$/, - '/codex-runtime-home/active/wsl/home' - ) - const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` - const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) - // Why: login-shell cleanup turns `exit 0` into status 1, so fall through. - execFileSync( - 'wsl.exe', - [ - '-d', - distro, - '--exec', - 'bash', - '-lc', - [ - 'set -e', - `if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, - `elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, - 'else', - `mkdir -p ${quotePosixShell(activeLinuxParentPath)}`, - `rm -rf -- ${quotePosixShell(nextLinuxPath)}`, - `ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`, - `mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`, - 'fi' - ].join('\n') - ], - // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes - // a conhost and steals foreground for up to the timeout (#10488). - { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } - ) + const settings = this.store.getSettings() + const drains: Promise[] = [] + for (const [selectedDistroKey, accountId] of Object.entries( + normalizeCodexRuntimeSelection(settings).wsl + )) { + if (!accountId) { + continue + } + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + if (!account || account.managedHomeRuntime !== 'wsl') { + continue + } + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() || null + : selectedDistroKey.trim() || null + if (distro) { + drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) + } + } + await Promise.all(drains) } - private dirnameLinuxPath(value: string): string { - const index = value.lastIndexOf('/') - return index > 0 ? value.slice(0, index) : '/' + private async resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise { + const accounts = this.store.getSettings().codexManagedAccounts.filter((account) => { + const parsedHome = parseWslUncPath(account.managedHomePath) + return parsedHome?.distro.toLowerCase() === distro.toLowerCase() + }) + const accountHomes = accounts.flatMap((account) => { + const parsedHome = parseWslUncPath(account.managedHomePath) + return parsedHome ? [{ account, linuxPath: parsedHome.linuxPath }] : [] + }) + const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null + let reads: WslCodexAuthRead[] + try { + reads = await readWslCodexAuths(distro, [ + ...accountHomes.map(({ linuxPath }) => linuxPath), + ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) + ]) + } catch { + reads = accountHomes.map(() => ({ kind: 'unreadable' })) + if (parsedSystemHome) { + reads.push({ kind: 'unreadable' }) + } + } + const authReads = new Map( + accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) + ) + const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { + accounts, + authReads + }) + if (match.kind === 'ambiguous') { + return null + } + if (match.kind === 'matched') { + const parsedHome = parseWslUncPath(match.account.managedHomePath) + if (!parsedHome || parsedHome.distro.toLowerCase() !== distro.toLowerCase()) { + return null + } + return { + authContents: match.managedAuthContents, + linuxHomePath: parsedHome.linuxPath + } + } + + if (!systemHome || !parsedSystemHome) { + return null + } + const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } + if (systemAuth.kind !== 'present') { + return null + } + return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) + ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } + : null } private joinWslPath(basePath: string, ...segments: string[]): string { @@ -1286,40 +1129,13 @@ export class CodexRuntimeHomeService { return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target } - private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { - const home = this.getWslSystemCodexHomePath(target) - return home ? this.joinWslPath(home, 'auth.json') : null - } - - private seedWslRuntimeHome( - runtimeHomePath: string, - activeAccount: CodexManagedAccount | null, - distro: string - ): void { - const runtimeConfigPath = join(runtimeHomePath, 'config.toml') - if (existsSync(runtimeConfigPath)) { - return - } - - const candidateHomes = [ - activeAccount?.managedHomePath, - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - ].filter((value): value is string => Boolean(value)) - for (const homePath of candidateHomes) { - const configPath = join(homePath, 'config.toml') - if (existsSync(configPath)) { - writeFileAtomically( - runtimeConfigPath, - prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath) - ) - return - } - } - } - private findManagedAccountForRuntimeAuth( runtimeAuthContents: string, - expectedAccountId?: string + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } ): CodexReadBackMatch { const matches: { account: CodexManagedAccount @@ -1327,18 +1143,17 @@ export class CodexRuntimeHomeService { managedAuthContents: string }[] = [] let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of this.store.getSettings().codexManagedAccounts) { + for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { if (expectedAccountId && account.id !== expectedAccountId) { continue } const managedAuthPath = join(account.managedHomePath, 'auth.json') - if (!existsSync(managedAuthPath)) { + let managedAuthContents: string + const suppliedRead = options?.authReads.get(account.id) + if (suppliedRead?.kind === 'missing') { continue } - let managedAuthContents: string - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { + if (suppliedRead?.kind === 'unreadable') { // Why: an unreadable home can never be compared, but letting the read // throw abandons the scan for every other account — dropping a refresh // the runtime home holds for one of them. Only its record can rule it @@ -1351,6 +1166,24 @@ export class CodexRuntimeHomeService { } continue } + if (suppliedRead?.kind === 'present') { + managedAuthContents = suppliedRead.contents + } else { + if (!existsSync(managedAuthPath)) { + continue + } + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + } if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { matches.push({ account, managedAuthPath, managedAuthContents }) } @@ -1372,10 +1205,6 @@ export class CodexRuntimeHomeService { return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) } - private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { - return codexAuthIsFresher(runtimeAuthContents, managedAuthContents) - } - private safeMigrateLegacySharedAuth(): void { const settings = this.store.getSettings() try { @@ -2082,15 +1911,6 @@ export class CodexRuntimeHomeService { return true } - private writeRuntimeAuthAtPath(authPath: string, contents: string): void { - if (this.fileContentsEqual(authPath, contents)) { - this.ensureOwnerOnlyMode(authPath) - return - } - mkdirSync(dirname(authPath), { recursive: true }) - writeFileAtomically(authPath, contents, { mode: 0o600 }) - } - /** * `true`/`false` only when the bytes were actually read; `null` when the file * could not be read at all. The old `catch { return false }` reported "these @@ -2397,14 +2217,3 @@ export class CodexRuntimeHomeService { rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) } } - -// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2). -export function prepareWslRuntimeSeedConfig( - configContents: string, - sourceHomePath: string -): string { - return prepareSystemConfigForFreshRuntimeMirror( - configContents, - parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath - ) -} diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index efc7ea3f2b9..81593c46013 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { createSettings } from './runtime-home-settings-test-fixtures' import { @@ -87,18 +87,16 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n') expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath - ) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( - '{"account":"wsl"}\n' + wslManagedHomePath ) + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: getRuntimeCodexHomePath() }) expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ kind: 'ready', - codexHomePath: wslRuntimeHomePath + codexHomePath: wslManagedHomePath }) } finally { if (originalPlatform) { @@ -107,7 +105,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('clears a selected WSL managed account when auth.json is missing', async () => { + it('keeps a selected WSL managed home when auth.json is temporarily missing', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -161,13 +159,11 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + managedHomePath ) - expect(store.updateSettings).toHaveBeenCalledWith({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - }) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(store.updateSettings).not.toHaveBeenCalled() + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -175,7 +171,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => { + it('launches WSL system default against its existing config without a runtime seed', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -214,21 +210,18 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + systemCodexHomePath ) const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') - const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - expect(runtimeConfig).toContain( - `model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'` - ) - expect(runtimeConfig).toContain('[projects."/home/alice/repo"]') - expect(runtimeConfig).not.toContain('[hooks.state.') + expect(existsSync(runtimeConfigPath)).toBe(false) + const systemConfigPath = join(systemCodexHomePath, 'config.toml') + const systemConfig = readFileSync(systemConfigPath, 'utf-8') + expect(systemConfig).toContain('model_instructions_file = "instructions.md"') + expect(systemConfig).toContain('[hooks.state.') - // Why: WSL runtime configs are seeded once; Codex writes trust into them - // afterwards, so a relaunch must not clobber the seeded file. - writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8') + writeFileSync(systemConfigPath, `${systemConfig}\n[projects."/tmp/x"]\n`, 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') + expect(readFileSync(systemConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -236,26 +229,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => { - const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service') - - // Why: real UNC sources cannot back live fs operations in tests, so pin - // the UNC -> Linux-side anchor translation on the extracted seed function. - expect( - prepareWslRuntimeSeedConfig( - 'model_instructions_file = "instructions.md"\n', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' - ) - ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") - expect( - prepareWslRuntimeSeedConfig( - 'model_instructions_file = "instructions.md"\n', - '\\\\wsl$\\Ubuntu\\home\\alice\\.codex' - ) - ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") - }) - - it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { + it('switches WSL accounts by selecting each account home directly', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -317,16 +291,17 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(service.prepareForCodexLaunch(target)).toBe(firstManagedHomePath) + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) store.updateSettings({ activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } } }) service.syncForCurrentSelection(target) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) + expect(service.prepareForCodexLaunch(target)).toBe(secondManagedHomePath) + expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -334,7 +309,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('does not use host auth baseline to accept stale WSL runtime auth', async () => { + it('ignores stale retired runtime auth when launching a managed WSL account', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -342,7 +317,6 @@ describe('CodexRuntimeHomeService', () => { getDefaultWslDistro: () => 'Ubuntu', getWslHome: () => wslHome })) - const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token') const wslManagedAuth = createCodexAuthJson( 'wsl@example.com', 'acct-wsl', @@ -355,7 +329,6 @@ describe('CodexRuntimeHomeService', () => { 'runtime-stale', 1_000 ) - const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) const wslManagedHomePath = createManagedAuth( testState.userDataDir, 'wsl-account', @@ -374,17 +347,6 @@ describe('CodexRuntimeHomeService', () => { const store = createStore( createSettings({ codexManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedHomePath: hostManagedHomePath, - providerAccountId: 'acct-host', - workspaceLabel: null, - workspaceAccountId: 'acct-host', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, { id: 'wsl-account', email: 'wsl@example.com', @@ -395,14 +357,13 @@ describe('CodexRuntimeHomeService', () => { providerAccountId: 'acct-wsl', workspaceLabel: null, workspaceAccountId: 'acct-wsl', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 } ], - activeCodexManagedAccountId: 'host-account', activeCodexManagedAccountIdsByRuntime: { - host: 'host-account', + host: null, wsl: { Ubuntu: 'wsl-account' } } }) @@ -413,148 +374,10 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + wslManagedHomePath ) expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000) - const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500) - const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'wsl@example.com', - managedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-wsl', - workspaceLabel: null, - workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'account-1' } - } - }) - ) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') - writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') - - service.clearLastWrittenAuthJson('account-1') - service.syncForCurrentSelection(target) - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('reads active WSL token refreshes back before restart using the selected distro', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed', 1_000) - const refreshedAuth = createCodexAuthJson( - 'wsl@example.com', - 'acct-wsl', - 'runtime-refreshed', - 2_000 - ) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'wsl@example.com', - managedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: null, - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-wsl', - workspaceLabel: null, - workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'account-1' } - } - }) - ) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - - service.syncActiveWslSelectionsBeforeRestart() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index 05cfa12e240..fe91d223a40 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type * as WslPaths from '../../shared/wsl-paths' import { createSettings } from './runtime-home-settings-test-fixtures' @@ -34,62 +34,7 @@ describe('CodexRuntimeHomeService', () => { teardownRuntimeHomeTest() }) - it('builds a valid WSL legacy active-home migration shell command', async () => { - const execFileSyncMock = vi.fn() - vi.doMock('node:child_process', () => ({ execFileSync: execFileSyncMock })) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService( - createStore(createSettings()) as never - ) as unknown as { - migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void - } - - service.migrateLegacyWslActiveHomePointer( - 'Ubuntu', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' - ) - - expect(execFileSyncMock).toHaveBeenCalledTimes(1) - const firstCall = execFileSyncMock.mock.calls[0] - expect(firstCall).toBeDefined() - const [command, args] = firstCall as [string, string[]] - expect(command).toBe('wsl.exe') - expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc']) - expect(args).toHaveLength(6) - - const shellCommand = args[5] - expect(shellCommand).toContain( - "if [ ! -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" - ) - expect(shellCommand).toContain( - "elif [ -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" - ) - expect(shellCommand).toContain( - "mkdir -p '/home/alice/.local/share/orca/codex-runtime-home/active/wsl'" - ) - expect(shellCommand).toContain( - "ln -s -- '/home/alice/.local/share/orca/codex-runtime-home/home' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" - ) - expect(shellCommand).toContain( - "mv -Tf -- '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" - ) - expect(shellCommand).toContain( - "' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home'" - ) - expect(shellCommand).not.toContain('[! -L') - expect(shellCommand).not.toContain('mv -Tf--') - expect(shellCommand).not.toContain('$1') - expect(shellCommand).not.toContain('$2') - expect(shellCommand).not.toContain('$3') - expect(shellCommand).not.toContain('exit 0') - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('starts WSL session bridging after materializing the WSL launch home', async () => { + it('skips WSL session bridging when system default already uses its direct home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -124,17 +69,13 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + wslSystemHomePath ) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ - distro: 'Ubuntu', - systemCodexHomePath: wslSystemHomePath, - managedCodexHomePath: wslRuntimeHomePath - }) - const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md') - expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') - expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled() + expect(readFileSync(join(wslSystemHomePath, 'AGENTS.md'), 'utf-8')).toBe( + '# WSL instructions\n' + ) + expect(existsSync(join(wslRuntimeHomePath, 'AGENTS.md'))).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -144,7 +85,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { + it('keeps WSL in-Codex setting changes in the direct system home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) vi.doMock('../codex/wsl-codex-session-bridge', () => ({ @@ -177,32 +118,20 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - // First launch seeds the runtime config and records the per-distro baseline. expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + join(wslHome, '.codex') ) const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json') - expect(existsSync(baselinePath)).toBe(true) + expect(existsSync(baselinePath)).toBe(false) - // A direct WSL Codex edit wins and is mirrored into Orca's runtime before - // the baseline advances, so later in-Orca changes remain promotable. - const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') - expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') - - // Codex now persists a /model change inside Orca's reconciled runtime. - writeFileSync( - runtimeConfigPath, - readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'), - 'utf-8' - ) + expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + writeFileSync(wslSystemConfigPath, 'model = "o4"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n') - // Baseline advances so the promoted value is not re-promoted forever. - expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') + expect(existsSync(baselinePath)).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -236,22 +165,13 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Ubuntu', systemCodexHomePath: '/home/me/.config/codex', - managedCodexHomePath: wslRuntimeHomePath + managedCodexHomePath: join(wslHome, '.codex') }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') @@ -262,7 +182,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { + it('starts WSL session bridging for the selected direct account home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -270,14 +190,6 @@ describe('CodexRuntimeHomeService', () => { startWslCodexSessionBridgeInBackground })) const wslHome = join(testState.userDataDir, 'debian-wsl-home') - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => null, getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null) @@ -287,10 +199,10 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate === wslRuntimeHomePath + candidate.includes('codex-accounts/debian-account/home') ? { distro: 'Debian', - linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' + linuxPath: '/home/alice/.local/share/orca/codex-accounts/debian-account/home' } : null } @@ -328,12 +240,12 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe( - wslRuntimeHomePath + managedHomePath ) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Debian', systemCodexHomePath: join(wslHome, '.codex'), - managedCodexHomePath: wslRuntimeHomePath + managedCodexHomePath: managedHomePath }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index a1ad86a0a5f..8e8965f6a40 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -53,15 +53,6 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const syncWslRuntime = vi.spyOn( - service as unknown as { - syncWslRuntimeForCurrentSelection: (target: { - runtime: 'wsl' - wslDistro?: string | null - }) => string | null - }, - 'syncWslRuntimeForCurrentSelection' - ) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } const expectedHome = join(wslHome, '.codex') @@ -73,7 +64,6 @@ describe('CodexRuntimeHomeService', () => { kind: 'ready', codexHomePath: expectedHome }) - expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -93,15 +83,6 @@ describe('CodexRuntimeHomeService', () => { const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token') const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth) const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth) - const runtimeAuthPath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home', - 'auth.json' - ) const store = createStore( createSettings({ codexManagedAccounts: [ @@ -147,9 +128,9 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({ kind: 'ready', - codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + codexHomePath: ubuntuHomePath }) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) + expect(readFileSync(join(ubuntuHomePath, 'auth.json'), 'utf-8')).toBe(ubuntuAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -228,7 +209,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('reads WSL system-default token refreshes back to WSL system auth', async () => { + it('keeps WSL system-default token refreshes in its direct home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -257,21 +238,11 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + writeFileSync(join(systemCodexHomePath, 'auth.json'), refreshedAuth, 'utf-8') - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') - - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -279,7 +250,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('preserves WSL system-default token refreshes after app restart', async () => { + it('does not overwrite direct WSL system auth from the retired runtime on restart', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -319,8 +290,8 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { diff --git a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts new file mode 100644 index 00000000000..40870fcd5ef --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts @@ -0,0 +1,70 @@ +import { runWslProcess } from '../wsl/wsl-runner' + +export type WslCodexAuthRead = + | { kind: 'missing' | 'unreadable' } + | { kind: 'present'; contents: string } + +export async function readWslCodexAuths( + distro: string, + linuxHomePaths: readonly string[] +): Promise { + if (linuxHomePaths.length === 0) { + return [] + } + const result = await runWslProcess({ + distro, + loginPath: 'none', + script: READ_AUTHS_SCRIPT, + args: linuxHomePaths, + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (result.code !== 0 || result.timedOut) { + return linuxHomePaths.map(() => ({ kind: 'unreadable' })) + } + const rows = result.stdout.split('\n') + return linuxHomePaths.map((_, index) => parseAuthReadRow(rows[index] ?? '')) +} + +export function decodeWslBase64Payload(encoded: string): string | null { + try { + const decoded = Buffer.from(encoded, 'base64') + const canonical = decoded.toString('base64').replace(/=+$/, '') + return canonical === encoded.replace(/=+$/, '') ? decoded.toString('utf8') : null + } catch { + return null + } +} + +function parseAuthReadRow(row: string): WslCodexAuthRead { + if (row === 'missing' || row === 'unreadable') { + return { kind: row } + } + if (!row.startsWith('present:')) { + return { kind: 'unreadable' } + } + const contents = decodeWslBase64Payload(row.slice('present:'.length)) + return contents === null ? { kind: 'unreadable' } : { kind: 'present', contents } +} + +const READ_AUTHS_SCRIPT = ` +set -eu +for home_path in "$@"; do + auth_path="$home_path/auth.json" + if [ ! -f "$auth_path" ]; then + if [ ! -e "$auth_path" ] && [ ! -L "$auth_path" ]; then + printf 'missing\n' + else + printf 'unreadable\n' + fi + continue + fi + if encoded=$(base64 < "$auth_path"); then + printf 'present:' + printf '%s' "$encoded" | tr -d '\n' + printf '\n' + else + printf 'unreadable\n' + fi +done +` diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 449c11ba711..4a4467bd219 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -266,6 +266,15 @@ export function hasRecordedLegacySharedCodexPane(): boolean { ) } +/** True when a retained WSL pane may still read the retired per-distro runtime home. */ +export function hasRecordedLegacyWslCodexPane(selectionKey: string): boolean { + return Object.values(readRegistryOrThrow().panes).some( + (record) => + (record.selectionKey === selectionKey || record.selectionKey === 'wsl:__default__') && + (record.homeRoute === undefined || record.homeRoute === 'wsl-home') + ) +} + /** True when startup may need to repair hooks for a retained managed host pane. */ export function hasRecordedManagedHostCodexPane(): boolean { return Object.values(readRegistry().panes).some( diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index 456b9bc08be..c862099e3ac 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -202,7 +202,7 @@ describe('resolveCodexPaneLaunchAccount', () => { ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account', - homeRoute: 'wsl-home' + homeRoute: 'account-home' }) }) diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index b99700da081..089c1975db3 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' import { getCodexSelectionLaneKey, getCodexSelectionTargetForAccount, @@ -80,9 +81,6 @@ function resolveCodexPaneHomeRoute(args: { settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneHomeRoute { - if (args.target.runtime === 'wsl') { - return 'wsl-home' - } if ( !args.launchCodexHomePath || normalizeRuntimePathForComparison(args.launchCodexHomePath) === @@ -93,10 +91,19 @@ function resolveCodexPaneHomeRoute(args: { const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) const accountOwnsHome = args.settings.codexManagedAccounts?.some( (account) => - getCodexSelectionTargetForAccount(account).runtime === 'host' && + getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) === + getCodexSelectionLaneKey(args.target) && normalizeRuntimePathForComparison(account.managedHomePath) === launchHome ) - return accountOwnsHome ? 'account-home' : 'shared-home' + if (accountOwnsHome) { + return 'account-home' + } + if (args.target.runtime === 'wsl') { + return parseWslUncPath(args.launchCodexHomePath)?.linuxPath.endsWith('/.codex') + ? 'real-home' + : 'wsl-home' + } + return 'shared-home' } /** undefined when no account owns the home; null means the system-default account. */ diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index bf2f7e5b5ea..c5deb104543 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -8,6 +8,7 @@ import { forgetCodexPaneAccount, getCodexPaneAccount, hasRecordedLegacySharedCodexPane, + hasRecordedLegacyWslCodexPane, hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, @@ -138,6 +139,36 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) + it('identifies only legacy runtime-home panes on the requested WSL lane', () => { + recordCodexPaneAccount('pty-legacy', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-old', + homeRoute: 'wsl-home' + }) + recordCodexPaneAccount('pty-direct', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-new', + homeRoute: 'account-home' + }) + recordCodexPaneAccount('pty-other-distro', { + selectionKey: 'wsl:Debian', + accountId: 'account-debian', + homeRoute: 'wsl-home' + }) + recordCodexPaneAccount('pty-default', { + selectionKey: 'wsl:__default__', + accountId: null, + homeRoute: 'wsl-home' + }) + + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) + forgetCodexPaneAccount('pty-legacy') + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) + forgetCodexPaneAccount('pty-default') + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false) + expect(hasRecordedLegacyWslCodexPane('wsl:Debian')).toBe(true) + }) + it('requests startup inventory only for managed host panes', () => { recordCodexPaneAccount('pty-real', { selectionKey: 'host', diff --git a/src/main/daemon/daemon-checkpoint-file.ts b/src/main/daemon/daemon-checkpoint-file.ts index 8031996a54c..a1dd53fc898 100644 --- a/src/main/daemon/daemon-checkpoint-file.ts +++ b/src/main/daemon/daemon-checkpoint-file.ts @@ -1,5 +1,6 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalModes } from './types' +import type { TerminalOwner } from '../../shared/terminal-owner' /** On-disk shape of checkpoint.json. Written by history-manager, read by * history-reader — one type so the generation pairing with output.log's @@ -17,6 +18,7 @@ export type TerminalCheckpointFile = { modes: TerminalModes scrollbackLines: number lastTitle?: string + terminalOwner?: TerminalOwner /** Ties this checkpoint to the output.log whose header carries the same * generation. Absent on checkpoints written before incremental logs. */ generation?: number diff --git a/src/main/daemon/daemon-durable-history-ownership-seed.test.ts b/src/main/daemon/daemon-durable-history-ownership-seed.test.ts new file mode 100644 index 00000000000..0e5fc0929ae --- /dev/null +++ b/src/main/daemon/daemon-durable-history-ownership-seed.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' +import type { TerminalSnapshot } from './types' + +const liveSnapshot = { + snapshotAnsi: 'prompt', + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/tmp', + cols: 80, + rows: 24, + scrollbackLines: 0, + modes: { + bracketedPaste: false, + alternateScreen: false, + applicationCursor: false, + mouseTracking: false + }, + terminalOwner: 'shell', + outputSequence: 7 +} as TerminalSnapshot + +describe('durable checkpoint ownership seeding without disk history', () => { + it('carries the live proof through a rebuild over benign pending records', async () => { + const durable = await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo: null, + pendingRecords: [{ kind: 'output', data: 'plain shell prompt\r\n' }] + }) + expect(durable.terminalOwner).toBe('shell') + }) + + it('revokes the live proof when pending records re-enter a TUI', async () => { + const durable = await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo: null, + pendingRecords: [{ kind: 'output', data: '\x1b[?1049hLIVE-TUI' }] + }) + expect(durable.terminalOwner).toBeUndefined() + }) +}) diff --git a/src/main/daemon/daemon-durable-history-snapshot.ts b/src/main/daemon/daemon-durable-history-snapshot.ts index c8d71736607..bc99fb566ce 100644 --- a/src/main/daemon/daemon-durable-history-snapshot.ts +++ b/src/main/daemon/daemon-durable-history-snapshot.ts @@ -1,6 +1,7 @@ import { ColdRestoreReplayWriter } from './cold-restore-replay-writer' import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' import { HeadlessEmulator } from './headless-emulator' +import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' import { isValidTerminalHistorySize } from './terminal-history-dimensions' import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' import type { PendingOutputRecord, TerminalSnapshot } from './types' @@ -12,6 +13,7 @@ type RestoreBase = { pendingEscapeTailAnsi?: string oscLinks?: TerminalSnapshot['oscLinks'] lastTitle?: string + terminalOwner?: TerminalSnapshot['terminalOwner'] cwd: string | null cols: number rows: number @@ -35,6 +37,7 @@ export function terminalSnapshotFromColdRestore( scrollbackLines: info.scrollbackLines ?? Math.max(0, countAnsiRows(info.scrollbackAnsi) - info.rows), ...(info.lastTitle ? { lastTitle: info.lastTitle } : {}), + ...(info.terminalOwner ? { terminalOwner: info.terminalOwner } : {}), ...(opts?.outputSequence !== undefined ? { outputSequence: opts.outputSequence } : {}) } } @@ -91,12 +94,29 @@ export async function buildDurableCheckpointSnapshot(opts: { } emulator.setCwd(base.cwd) } + // Why a scanner, not emulator state: ownership is lifecycle evidence bound + // to byte order — newer replayed output (a TUI starting) must revoke a + // persisted proof exactly as it would have live. + const ownershipScanner = new TerminalShellLifecycleScanner() + // Why the live fallback: with no disk history the live snapshot holds the + // only persisted proof for this boundary; the re-scan below can revoke it + // but never mint one, so the result is never less safe than dropping it. + ownershipScanner.seedOwner( + opts.restoreInfo ? opts.restoreInfo.terminalOwner : opts.liveSnapshot.terminalOwner + ) if (!(await replayPendingRecords(replay, pendingRecords))) { return opts.liveSnapshot } + for (const record of pendingRecords) { + if (record.kind === 'output') { + scanAllForOwnership(ownershipScanner, record.data) + } + } const snapshot = emulator.getSnapshot() + const terminalOwner = ownershipScanner.owner return { ...snapshot, + ...(terminalOwner ? { terminalOwner } : {}), ...(opts.liveSnapshot.outputSequence !== undefined ? { outputSequence: opts.liveSnapshot.outputSequence } : {}), @@ -122,12 +142,26 @@ function restoreBaseFrom(restoreInfo: ColdRestoreInfo): RestoreBase { : {}), oscLinks: restoreInfo.oscLinks, lastTitle: restoreInfo.lastTitle, + terminalOwner: restoreInfo.terminalOwner, cwd: restoreInfo.cwd, cols: restoreInfo.cols, rows: restoreInfo.rows } } +/** Replay can only carry or revoke a persisted proof, never mint one: the + * scanner has no confirmation source, so triggers are consumed as revocations. */ +function scanAllForOwnership(scanner: TerminalShellLifecycleScanner, data: string): void { + let rest = data + while (rest.length > 0) { + const events = scanner.scan(rest) + if (events.uncleanDeathTriggerEnd === undefined) { + return + } + rest = rest.slice(events.uncleanDeathTriggerEnd) + } +} + async function replayPendingRecords( replay: ColdRestoreReplayWriter, records: readonly PendingOutputRecord[] diff --git a/src/main/daemon/daemon-foreground-process-protocol.ts b/src/main/daemon/daemon-foreground-process-protocol.ts index c0d672ec159..a16d6464e88 100644 --- a/src/main/daemon/daemon-foreground-process-protocol.ts +++ b/src/main/daemon/daemon-foreground-process-protocol.ts @@ -10,6 +10,10 @@ export type ConfirmForegroundProcessRequest = Omit & { + type: 'confirmShellForeground' +} + export type InspectProcessRequest = Omit & { type: 'inspectProcess' } diff --git a/src/main/daemon/daemon-pty-adapter-test-harness.ts b/src/main/daemon/daemon-pty-adapter-test-harness.ts index 0e89847e1e5..f7676bbec2f 100644 --- a/src/main/daemon/daemon-pty-adapter-test-harness.ts +++ b/src/main/daemon/daemon-pty-adapter-test-harness.ts @@ -29,6 +29,7 @@ export function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle write: ReturnType void>> pause: ReturnType void>> resume: ReturnType void>> + confirmShellForeground: ReturnType Promise>> _simulateData: (data: string) => void _simulateExit: (code: number) => void } { @@ -38,6 +39,7 @@ export function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle // Why: getCwd falls back to OS pid lookup; an implausibly-high fake pid can't collide with a real process' cwd. pid: 999_999_999, getForegroundProcess: vi.fn(() => null), + confirmShellForeground: vi.fn(async () => false), write: vi.fn(), resize: vi.fn(), pause: vi.fn<() => void>(), diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index ef5fa687586..387036dd6c3 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -496,6 +496,37 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('getBufferSnapshot', () => { + it('publishes shell ownership only after the daemon proves the live PTY tree', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess.confirmShellForeground.mockResolvedValue(true) + + lastSubprocess._simulateData( + '\x1b[?1049h\x1b[?1003h\x1b[?1006hTUI\x1b]133;D;137\x07shell-marker' + ) + + await vi.waitFor(async () => { + await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({ + alternateScreen: false, + terminalOwner: 'shell' + }) + }) + await expect(adapter.confirmShellForeground(id)).resolves.toBe(true) + expect(lastSubprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + }) + + it('preserves live TUI modes when the daemon cannot prove shell ownership', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + + lastSubprocess._simulateData( + '\x1b[?1049h\x1b[?1003h\x1b[?1006hLIVE-TUI\x1b]133;D;0\x07nested-shell' + ) + + await vi.waitFor(() => expect(lastSubprocess.confirmShellForeground).toHaveBeenCalledTimes(1)) + const snapshot = await adapter.getBufferSnapshot(id) + expect(snapshot?.alternateScreen).toBe(true) + expect(snapshot?.terminalOwner).toBeUndefined() + }) + it('returns the daemon model with its absolute stream sequence', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) lastSubprocess._simulateData('complete hidden output\r\n') diff --git a/src/main/daemon/daemon-pty-buffer-snapshots.ts b/src/main/daemon/daemon-pty-buffer-snapshots.ts index cf65d85fa61..9fc0d10711e 100644 --- a/src/main/daemon/daemon-pty-buffer-snapshots.ts +++ b/src/main/daemon/daemon-pty-buffer-snapshots.ts @@ -64,7 +64,8 @@ export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl { ...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}), ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } - : {}) + : {}), + ...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {}) } } diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index d1dd3ad4e36..bbc44568501 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -72,6 +72,17 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot } } + async confirmShellForeground(id: string): Promise { + try { + const result = await this.client.request<{ confirmed: boolean }>('confirmShellForeground', { + sessionId: id + }) + return result.confirmed === true + } catch { + return false + } + } + async serialize(ids: string[]): Promise { const sessions: Record = {} for (const id of ids) { diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 0da991b6931..ab30878c9d8 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -185,6 +185,10 @@ export class DaemonPtyRouter implements IPtyProvider { return this.adapterFor(id).confirmForegroundProcess(id) } + async confirmShellForeground(id: string): Promise { + return (await this.adapterFor(id).confirmShellForeground?.(id)) ?? false + } + async serialize(ids: string[]): Promise { return this.current.serialize(ids) } diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index c40e8b6a9bd..f28750141f3 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -313,6 +313,9 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { ...(kittyKeyboardFlags !== undefined ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } : {}), + ...(reattachSnapshot.terminalOwner + ? { snapshotTerminalOwner: reattachSnapshot.terminalOwner } + : {}), isReattach: true, isAlternateScreen: isAltScreen, // Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch. diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 826c73b4370..1d5b75f6657 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -113,6 +113,10 @@ export class DaemonRequestRouter { request.payload.sessionId ) } + case 'confirmShellForeground': + return { + confirmed: await this.options.host.confirmShellForeground(request.payload.sessionId) + } case 'clearScrollback': this.options.host.clearScrollback(request.payload.sessionId) return {} @@ -213,13 +217,13 @@ export class DaemonRequestRouter { return { retiring } } - private getSnapshot(sessionId: string, requestedRows: unknown): unknown { + private async getSnapshot(sessionId: string, requestedRows: unknown): Promise { const startedAt = performance.now() const scrollbackRows = typeof requestedRows === 'number' && Number.isFinite(requestedRows) ? Math.max(0, Math.min(50_000, Math.floor(requestedRows))) : undefined - const snapshot = this.options.host.getSnapshot(sessionId, { scrollbackRows }) + const snapshot = await this.options.host.getSettledSnapshot(sessionId, { scrollbackRows }) const snapshotMs = performance.now() - startedAt if (snapshotMs >= 25) { recordDaemonStreamBacklogEvent('slowGetSnapshot', { diff --git a/src/main/daemon/daemon-restore-scrollback-depth.test.ts b/src/main/daemon/daemon-restore-scrollback-depth.test.ts index e6fe7ca35ff..b2c2c7c5028 100644 --- a/src/main/daemon/daemon-restore-scrollback-depth.test.ts +++ b/src/main/daemon/daemon-restore-scrollback-depth.test.ts @@ -15,6 +15,7 @@ import { HistoryManager } from './history-manager' import { HistoryReader } from './history-reader' import { TerminalHost } from './terminal-host' import type { DaemonFileLog } from './daemon-file-log' +import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' import type { PendingOutputRecord, TerminalSnapshot } from './types' import type { SubprocessHandle } from './session-subprocess-handle' @@ -211,6 +212,38 @@ describe('STA-4091 previously recoverable restore depth', () => { } }) + it('revokes persisted shell ownership when newer output starts a TUI', async () => { + const restoreInfo: ColdRestoreInfo = { + snapshotAnsi: 'stale-tui', + scrollbackAnsi: '', + rehydrateSequences: '\x1b[?1049h', + cwd: '/tmp', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: true, + applicationCursor: false, + alternateScreen: true + }, + terminalOwner: 'shell' + } + const liveSnapshot = { + ...restoreInfo, + scrollbackLines: 0, + outputSequence: 42 + } as TerminalSnapshot + + const durable = await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo, + pendingRecords: [{ kind: 'output', data: '\x1b[?1049hLIVE-TUI' }] + }) + + expect(durable.modes.alternateScreen).toBe(true) + expect(durable.terminalOwner).toBeUndefined() + }) + it('falls back to the live window when pending resize records are invalid', async () => { const liveSnapshot = { snapshotAnsi: 'live-only', diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index afd603c3943..f0e183bcb20 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -194,6 +194,9 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { async confirmForegroundProcess(id: string): Promise { return this.providerFor(id).confirmForegroundProcess?.(id) ?? null } + async confirmShellForeground(id: string): Promise { + return (await this.providerFor(id).confirmShellForeground?.(id)) ?? false + } async serialize(ids: string[]): Promise { return this.fallback.serialize(ids) diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts index 3cf04164d28..115b795202d 100644 --- a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -1,6 +1,7 @@ import type * as pty from 'node-pty' import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process' +import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation' import { judgeCachedAgentJobEvidence, WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS @@ -37,6 +38,7 @@ export type PtyForegroundProcessTracker = { markDead(): void getForegroundProcess(): string | null confirmForegroundProcess(): Promise + confirmShellForeground(): Promise } export function createPtyForegroundProcessTracker(args: { @@ -306,6 +308,8 @@ export function createPtyForegroundProcessTracker(args: { } catch { return null } - } + }, + confirmShellForeground: () => + confirmPtyShellForeground({ process: proc, shellPath: args.shellPath, isDead: args.isDead }) } } diff --git a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts new file mode 100644 index 00000000000..2fdd75e3518 --- /dev/null +++ b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts @@ -0,0 +1,24 @@ +import type * as pty from 'node-pty' +import { confirmShellForegroundProcess } from '../../providers/agent-foreground-process' +import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' + +/** Fresh execution-host proof that the spawned shell owns the PTY foreground: + * a post-request process inspection (POSIX `ps`, Windows job membership), + * never cached state. */ +export async function confirmPtyShellForeground(args: { + process: pty.IPty + shellPath: string + isDead: () => boolean +}): Promise { + if (args.isDead() || !args.process.pid) { + return false + } + const confirmed = await confirmShellForegroundProcess( + args.process.pid, + args.shellPath, + process.platform === 'win32' + ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(args.process) } + : {} + ) + return !args.isDead() && confirmed +} diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts index a56d0fc3ae2..e2abd59c6ea 100644 --- a/src/main/daemon/pty-subprocess/subprocess-handle.ts +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -71,6 +71,7 @@ export function createDaemonPtySubprocessHandle(args: { : {}), getForegroundProcess: foreground.getForegroundProcess, confirmForegroundProcess: foreground.confirmForegroundProcess, + confirmShellForeground: foreground.confirmShellForeground, write: (data) => { if (dead) { return diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts new file mode 100644 index 00000000000..c249e4d1d31 --- /dev/null +++ b/src/main/daemon/session-output-pipeline.ts @@ -0,0 +1,33 @@ +import { SessionOutputPlane } from './session-output-plane' +import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { SubprocessHandle } from './session-subprocess-handle' + +/** The session's ordered output pipeline: the recovery barrier feeding the + * output plane. Built together because the barrier's owner is what the + * plane's snapshots publish, and the plane's emit is the barrier's sink. */ +export function createSessionOutputPipeline(opts: { + cols: number + rows: number + scrollback?: number | undefined + wslDistro?: string | undefined + historySeedChunks?: readonly string[] | undefined + subprocess: SubprocessHandle + isAlive: () => boolean +}): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { + let barrier: TerminalShellRecoveryBarrier | null = null + const output = new SessionOutputPlane({ + cols: opts.cols, + rows: opts.rows, + scrollback: opts.scrollback, + wslDistro: opts.wslDistro, + historySeedChunks: opts.historySeedChunks, + getTerminalOwner: () => barrier?.getOwner() + }) + const recoveryBarrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: async () => (await opts.subprocess.confirmShellForeground?.()) ?? false, + release: (emission) => output.emit(emission), + isAlive: opts.isAlive + }) + barrier = recoveryBarrier + return { output, recoveryBarrier } +} diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index 408e95545b8..507ca01a927 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -4,6 +4,9 @@ import { StartupDeviceAttributesQueryFilter } from './startup-device-attributes- import { normalizePtySize } from './daemon-pty-size' import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types' +import type { TerminalOwner } from '../../shared/terminal-owner' +import type { SubprocessHandle } from './session-subprocess-handle' +import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' // Why: bounds in-memory pending output when no client drains it; past the cap we drop records and flag // overflow so the next take falls back to one full snapshot. UTF-16 units; worst-case wire is ~6x, under NDJSON_MAX_LINE_BYTES (16MB). @@ -21,6 +24,9 @@ export type SessionOutputPlaneOptions = { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined + /** Read from the recovery barrier at snapshot time; the barrier scans bytes + * before this plane receives them, so its owner never lags the emulator. */ + getTerminalOwner?: (() => TerminalOwner | undefined) | undefined } /** Everything downstream of the PTY: the scrollback emulator, the pending-output record buffer that @@ -28,6 +34,7 @@ export type SessionOutputPlaneOptions = { export class SessionOutputPlane { readonly historySeeded: boolean | undefined private readonly emulator: HeadlessEmulator + private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined private attachedClients: AttachedClient[] = [] private pendingOutputRecords: PendingOutputRecord[] = [] private pendingOutputBytes = 0 @@ -56,6 +63,7 @@ export class SessionOutputPlane { opts.historySeedChunks === undefined ? undefined : opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk)) + this.readTerminalOwner = opts.getTerminalOwner } get responderParser(): HeadlessEmulator['responderParser'] { @@ -103,9 +111,18 @@ export class SessionOutputPlane { this.record({ kind: 'resize', cols, rows }) } - clearScrollback(): void { + clearScrollback(subprocess: SubprocessHandle, isGatingWrites: boolean): void { + if (this.disposed) { + return + } this.emulator.clearScrollback() this.record({ kind: 'clear' }) + subprocess.clear?.() + nudgePowerShellPromptRepaint({ + subprocess, + isGatingWrites, + isCursorOnEmptyPromptLine: () => this.isCursorOnEmptyPromptLine() + }) } isCursorOnEmptyPromptLine(): boolean { @@ -116,11 +133,29 @@ export class SessionOutputPlane { return this.emulator.getCwd() } + getTerminalOwner(): TerminalOwner | undefined { + return this.readTerminalOwner?.() + } + + /** FIFO fence over the emulator's async parse queue: resolves once every + * write issued before this call has been parsed into the model. */ + flushParsedWrites(): Promise { + if (this.disposed) { + return Promise.resolve() + } + return this.emulator.write('') + } + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { if (this.disposed) { return null } - return { ...this.emulator.getSnapshot(opts), outputSequence: this._outputSequence } + const terminalOwner = this.readTerminalOwner?.() + return { + ...this.emulator.getSnapshot(opts), + ...(terminalOwner ? { terminalOwner } : {}), + outputSequence: this._outputSequence + } } getPartialEscapeTailAnsi(): string { diff --git a/src/main/daemon/session-shell-recovery.test.ts b/src/main/daemon/session-shell-recovery.test.ts new file mode 100644 index 00000000000..7a5ae13663c --- /dev/null +++ b/src/main/daemon/session-shell-recovery.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest' +import { Session } from './session' +import type { SubprocessHandle } from './session-subprocess-handle' + +function createSubprocess() { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const confirmShellForeground = vi.fn( + () => new Promise((resolve) => void (resolveConfirm = resolve)) + ) + const handle = { + pid: 999, + getForegroundProcess: () => null, + confirmShellForeground, + write: () => {}, + resize: () => {}, + pause: () => {}, + resume: () => {}, + kill: () => {}, + forceKill: () => {}, + signal: () => {}, + terminateOwnedTree: () => 'unavailable' as const, + onData(cb: (data: string) => void) { + onData = cb + }, + onExit(cb: (code: number) => void) { + onExit = cb + }, + dispose: () => {} + } as unknown as SubprocessHandle + return { + handle, + confirmShellForeground, + emit: (data: string) => onData?.(data), + exit: (code: number) => onExit?.(code), + confirm: (v: boolean) => resolveConfirm?.(v) + } +} + +describe('Session shell-owned recovery through the output barrier', () => { + it('delivers post-kill shell output into the normalized snapshot instead of the discarded alt buffer', async () => { + const sub = createSubprocess() + const session = new Session({ + sessionId: 's1', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false + } as never) + + sub.emit('before-tui\r\n\x1b[?1049hTUI-FRAME\x1b]133;D;137\x07SHELL-PROMPT-MARKER') + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalledTimes(1)) + sub.confirm(true) + await session.settleShellOwnershipConfirmation() + await new Promise((r) => setTimeout(r, 20)) + + const snap = session.getSnapshot()! + const content = (snap.scrollbackAnsi ?? '') + snap.snapshotAnsi + expect(snap.terminalOwner).toBe('shell') + expect(snap.modes.alternateScreen).toBe(false) + expect(content).toContain('SHELL-PROMPT-MARKER') + expect(content).toContain('before-tui') + session.dispose() + }) + + it('keeps split escapes intact and revokes stale proof when a snapshot lands mid-sequence', async () => { + const sub = createSubprocess() + const session = new Session({ + sessionId: 's2', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false + } as never) + + sub.emit('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalledTimes(1)) + sub.confirm(true) + await session.settleShellOwnershipConfirmation() + await new Promise((r) => setTimeout(r, 20)) + expect(session.getSnapshot()?.terminalOwner).toBe('shell') + + // Live stream continues with a split mode-enable; snapshot lands inside the window. + sub.emit('marker\x1b[?10') + await new Promise((r) => setTimeout(r, 10)) + const mid = session.getSnapshot()! + expect(mid.pendingEscapeTailAnsi).toBe('\x1b[?10') + + sub.emit('49h') + await new Promise((r) => setTimeout(r, 20)) + const after = session.getSnapshot()! + expect(after.modes.alternateScreen).toBe(true) + expect(after.terminalOwner).toBeUndefined() + expect(after.snapshotAnsi).not.toContain('49h') + session.dispose() + }) + + it('flushes queued bytes to clients when the shell exits mid-proof instead of dropping them', async () => { + const sub = createSubprocess() + // Models TerminalHost.reapSession: exit and disposal are one synchronous chain. + const session: Session = new Session({ + sessionId: 's3', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false, + onExit: () => session.dispose() + } as never) + const received: string[] = [] + const exits: number[] = [] + session.attachClient({ + onData: (data: string) => received.push(data), + onExit: (code: number) => exits.push(code) + }) + + sub.emit('\x1b[?1049hTUI\x1b]133;D;137\x07\r\nprompt-after-death') + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalledTimes(1)) + // The shell exits before the proof settles — sub-millisecond in production, + // so it essentially always wins the race against the ps fork. + sub.exit(0) + + expect(received.join('')).toContain('prompt-after-death') + expect(exits).toEqual([0]) + // No injection either: an unproven episode flushes unmodified. + expect(received.join('')).not.toContain('\x1b[?1049l') + }) + + it('flushes nested episodes stacked inside one proof window when the shell exits', async () => { + const sub = createSubprocess() + const session: Session = new Session({ + sessionId: 's4', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false, + onExit: () => session.dispose() + } as never) + const received: string[] = [] + session.attachClient({ onData: (data: string) => received.push(data), onExit: () => {} }) + + // Three alt-screen death cycles inside one proof window: each flush pass can + // re-enter pending on the next trigger, so a single-level flush loses the tail. + sub.emit( + '\x1b[?1049hT1\x1b]133;D;1\x07AAA\x1b[?1049hT2\x1b]133;D;2\x07BBB\x1b[?1049hT3\x1b]133;D;3\x07CCC' + ) + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalled()) + sub.exit(0) + + const joined = received.join('') + for (const marker of ['AAA', 'BBB', 'CCC']) { + expect(joined).toContain(marker) + } + expect(joined).not.toContain('\x1b[?1049l') + }) +}) diff --git a/src/main/daemon/session-subprocess-handle.ts b/src/main/daemon/session-subprocess-handle.ts index 42f2bd43dee..f14469afbb4 100644 --- a/src/main/daemon/session-subprocess-handle.ts +++ b/src/main/daemon/session-subprocess-handle.ts @@ -9,6 +9,8 @@ export type SubprocessHandle = { getForegroundProcess(): string | null /** Await process-table evidence captured after this confirmation request. */ confirmForegroundProcess?(): Promise + /** Proves a fresh post-boundary PTY process tree contains only the shell. */ + confirmShellForeground?(): Promise /** True when shell launch args already delivered the startup command, so the host skips its stdin fallback write. */ startupCommandDeliveredInShellArgs?: boolean /** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index ea37c9237e5..191b8ee7eaa 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -41,6 +41,7 @@ function createMockSubprocess() { getForegroundProcess(): string | null { return this.foregroundProcess }, + confirmShellForeground: vi.fn(async () => true), write(data: string) { written.push(data) }, @@ -116,6 +117,7 @@ describe('Session', () => { ownerBackend?: 'posix-pty' | 'windows-conpty' | 'windows-wsl' wslDistro?: string reportReadinessEvent?: (event: string, details: Record) => void + historySeedChunks?: readonly string[] }): Session { session = new Session({ sessionId: 'test-session', @@ -125,6 +127,7 @@ describe('Session', () => { ...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}), wslDistro: opts?.wslDistro, subprocess, + historySeedChunks: opts?.historySeedChunks, ...(opts?.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), shellReadySupported: opts?.shellReadySupported ?? false, ...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}), @@ -163,6 +166,53 @@ describe('Session', () => { }) describe('data flow', () => { + it('does not confirm shell ownership from historical replay bytes', () => { + createSession({ + historySeedChunks: ['\x1b[?1049hOLD-TUI\x1b]133;D;137\x07old-shell-marker'] + }) + + expect(subprocess.confirmShellForeground).not.toHaveBeenCalled() + expect(session.getSnapshot()?.terminalOwner).toBeUndefined() + }) + + it('answers concurrent runtime confirmations from one episode inspection', async () => { + let resolveConfirmation: ((confirmed: boolean) => void) | undefined + subprocess.confirmShellForeground.mockImplementation( + () => new Promise((resolve) => void (resolveConfirmation = resolve)) + ) + createSession() + + // Why no inspection without a candidate: the RPC reads the barrier's + // settled verdict; it must never mint proof the byte stream didn't ask for. + await expect(session.confirmShellForeground()).resolves.toBe(false) + expect(subprocess.confirmShellForeground).not.toHaveBeenCalled() + + subprocess.simulateData('\x1b[?1049hTUI\x1b]133;D;137\x07') + const first = session.confirmShellForeground() + const second = session.confirmShellForeground() + expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + resolveConfirmation?.(true) + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + }) + + it('reuses the parser confirmation for a concurrent runtime request', async () => { + let resolveConfirmation: ((confirmed: boolean) => void) | undefined + subprocess.confirmShellForeground.mockImplementation( + () => new Promise((resolve) => void (resolveConfirmation = resolve)) + ) + createSession() + + subprocess.simulateData('\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker') + const runtimeConfirmation = session.confirmShellForeground() + expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + resolveConfirmation?.(true) + + await expect(runtimeConfirmation).resolves.toBe(true) + await vi.waitFor(() => expect(session.getSnapshot()?.terminalOwner).toBe('shell')) + }) + it('forwards subprocess data to attached clients', () => { createSession() const received: string[] = [] diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 2862a01e6f1..b0f1dfa538d 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -1,12 +1,13 @@ import { isValidPtySize } from './daemon-pty-size' -import { SessionOutputPlane, type AttachedClient } from './session-output-plane' +import type { SessionOutputPlane, AttachedClient } from './session-output-plane' +import { createSessionOutputPipeline } from './session-output-pipeline' import { SessionProducerPause } from './session-producer-pause' import { SessionShellReadyBarrier } from './session-shell-ready-barrier' +import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import { SessionTerminationController, IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS } from './session-termination-controller' -import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' import type { SubprocessHandle } from './session-subprocess-handle' import type { JobTerminationOutcome } from '../windows/windows-pty-job' import type { SessionOptions } from './session-options' @@ -38,6 +39,7 @@ export class Session { private readonly shellReady: SessionShellReadyBarrier private readonly termination: SessionTerminationController private readonly startupIngress: PtyStartupIngress + private readonly recoveryBarrier: TerminalShellRecoveryBarrier constructor(opts: SessionOptions) { this.sessionId = opts.sessionId @@ -46,13 +48,17 @@ export class Session { this.wslDistro = opts.wslDistro ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit - this.output = new SessionOutputPlane({ + const pipeline = createSessionOutputPipeline({ cols: opts.cols, rows: opts.rows, scrollback: opts.scrollback, wslDistro: opts.wslDistro, - historySeedChunks: opts.historySeedChunks + historySeedChunks: opts.historySeedChunks, + subprocess: this.subprocess, + isAlive: () => !this._disposed && this._state !== 'exited' }) + this.output = pipeline.output + this.recoveryBarrier = pipeline.recoveryBarrier this.producerPause = new SessionProducerPause(this.subprocess) this.termination = new SessionTerminationController({ sessionId: this.sessionId, @@ -78,10 +84,14 @@ export class Session { ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), ...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), write: (data) => this.subprocess.write(data), - onEmission: (emission) => this.output.emit(emission) + onEmission: (emission) => this.recoveryBarrier.accept(emission) }) this.shellReady.startPromptReadinessProbe() - this.subprocess.onData((data) => this.handleSubprocessData(data)) + this.subprocess.onData((data) => { + if (!this._disposed) { + this.shellReady.ingestSubprocessData(data) + } + }) this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) } @@ -150,10 +160,7 @@ export class Session { } resize(cols: number, rows: number): void { - if (this._state === 'exited' || this._disposed) { - return - } - if (!isValidPtySize(cols, rows)) { + if (this._state === 'exited' || this._disposed || !isValidPtySize(cols, rows)) { return } this.output.resize(cols, rows) @@ -253,22 +260,26 @@ export class Session { return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess() } + confirmShellForeground(): Promise { + return this.recoveryBarrier.confirmOwnerSettled() + } + + async settleShellOwnershipConfirmation(): Promise { + await this.recoveryBarrier.awaitProofSettled() + // Why the fence: a snapshot at settle-resolution must not race the drained prompt's async parse. + await this.output.flushParsedWrites() + } + clearScrollback(): void { - if (this._disposed) { - return - } - this.output.clearScrollback() - this.subprocess.clear?.() - nudgePowerShellPromptRepaint({ - subprocess: this.subprocess, - isGatingWrites: this.shellReady.isGatingWrites, - isCursorOnEmptyPromptLine: () => this.output.isCursorOnEmptyPromptLine() - }) + this.output.clearScrollback(this.subprocess, this.shellReady.isGatingWrites) } prepareForFinalSnapshot(): string { const held = this.shellReady.releaseHeldBytes() this.startupIngress.snapshotBarrier() + // Why last: snapshotBarrier can emit held spans into the barrier, and a + // teardown checkpoint mid-episode must not lose the barrier's queued bytes. + this.recoveryBarrier.flushPending() return held } @@ -282,6 +293,10 @@ export class Session { this.shellReady.releaseDeviceAttributes() this.shellReady.releaseHeldBytes() this.startupIngress.drainAndClose() + // Why after drainAndClose (and before clearClients below): a dispose + // mid-episode must deliver the barrier's queued bytes — drained ingress + // included — while clients are attached and the emulator accepts writes. + this.recoveryBarrier.flushPending() const wasTerminating = this.termination.isTerminating && this._state !== 'exited' const clientsToNotify = wasTerminating ? this.output.snapshotClients() : [] if (wasTerminating) { @@ -299,6 +314,7 @@ export class Session { this.output.clearClients() this.shellReady.clearPendingWrites() + this.recoveryBarrier.dispose() this.output.disposeEmulator() for (const client of clientsToNotify) { @@ -338,13 +354,6 @@ export class Session { this.termination.disposeSubprocessHandle() } - private handleSubprocessData(data: string): void { - if (this._disposed) { - return - } - this.shellReady.ingestSubprocessData(data) - } - private handleSubprocessExit(code: number, cause?: TerminalExitCause): void { this.termination.markPhysicalExit() if (this._disposed) { @@ -355,6 +364,11 @@ export class Session { this.shellReady.disposePromptReadinessProbe() this.shellReady.releaseHeldBytes() this.startupIngress.drainAndClose() + // Why after drainAndClose: drained ingress bytes re-enter the barrier and can + // open a fresh episode; flushing here delivers them too. A shell exiting + // mid-proof must not strand the queued post-133;D prompt — those bytes belong + // to clients, records, and history before broadcastExit below. + this.recoveryBarrier.flushPending() this._exitCode = code this._state = 'exited' this.termination.clearTerminating() diff --git a/src/main/daemon/terminal-checkpoint-serializer.test.ts b/src/main/daemon/terminal-checkpoint-serializer.test.ts index 00be8f39eee..b53e207aa52 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.test.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.test.ts @@ -121,6 +121,25 @@ describe('terminal checkpoint serializer', () => { } }) + it('preserves shell ownership when an oversized alternate-screen checkpoint is trimmed', async () => { + const input = snapshot({ + snapshotAnsi: `\x1b[?1049h${'row\r\n'.repeat(500)}visible`, + rehydrateSequences: '\x1b[?1049h', + terminalOwner: 'shell', + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: true + }, + scrollbackLines: 500 + }) + + const serialized = await serializeTerminalCheckpointWithinLimit(input, metadata, 2_048) + + expect(JSON.parse(serialized)).toMatchObject({ terminalOwner: 'shell' }) + }) + it('rejects an oversized escaped candidate without materializing it', async () => { const oversized = String.fromCharCode(0).repeat(100_000) const stringify = vi.spyOn(JSON, 'stringify') diff --git a/src/main/daemon/terminal-checkpoint-serializer.ts b/src/main/daemon/terminal-checkpoint-serializer.ts index bf72f42cb70..8776fb55588 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.ts @@ -27,6 +27,7 @@ function checkpointFile( modes: snapshot.modes, scrollbackLines: snapshot.scrollbackLines, ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}), + ...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {}), generation: metadata.generation, ...(metadata.pendingOutputSeq !== undefined ? { pendingOutputSeq: metadata.pendingOutputSeq } @@ -260,7 +261,10 @@ export async function serializeTerminalCheckpointWithinLimit( const emulator = await replaySnapshot(snapshot) try { - const visibleOnly = emulator.getSnapshot({ scrollbackRows: 0 }) + // Why carried, not re-derived: trimming rows cannot change who owned the + // terminal at this checkpoint's boundary. + const ownership = snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {} + const visibleOnly = { ...emulator.getSnapshot({ scrollbackRows: 0 }), ...ownership } let bestJson = stringifyWithinLimit(checkpointFile(visibleOnly, metadata), maxBytes) if (bestJson === null) { throw new Error('Terminal checkpoint metadata exceeds byte limit') @@ -270,7 +274,7 @@ export async function serializeTerminalCheckpointWithinLimit( let high = visibleOnly.scrollbackLines while (low <= high) { const rows = low + Math.floor((high - low) / 2) - const candidate = emulator.getSnapshot({ scrollbackRows: rows }) + const candidate = { ...emulator.getSnapshot({ scrollbackRows: rows }), ...ownership } const candidateJson = stringifyWithinLimit(checkpointFile(candidate, metadata), maxBytes) if (candidateJson === null) { high = rows - 1 diff --git a/src/main/daemon/terminal-history-checkpoint-reader.ts b/src/main/daemon/terminal-history-checkpoint-reader.ts index 3132290a512..13007fb60af 100644 --- a/src/main/daemon/terminal-history-checkpoint-reader.ts +++ b/src/main/daemon/terminal-history-checkpoint-reader.ts @@ -4,6 +4,7 @@ import { readTerminalHistoryJsonAsync } from './terminal-history-file-reader' import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits' import { isValidTerminalHistorySize } from './terminal-history-dimensions' import type { TerminalCheckpointFile, TerminalModes } from './types' +import { parseTerminalOwner } from '../../shared/terminal-owner' export type TerminalHistoryCheckpointRead = | { status: 'missing' } @@ -49,7 +50,9 @@ function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFi (checkpoint.oscLinks === undefined || isTerminalOscLinkRanges(checkpoint.oscLinks)) && (checkpoint.pendingEscapeTailAnsi === undefined || typeof checkpoint.pendingEscapeTailAnsi === 'string') && - (checkpoint.lastTitle === undefined || typeof checkpoint.lastTitle === 'string') + (checkpoint.lastTitle === undefined || typeof checkpoint.lastTitle === 'string') && + (checkpoint.terminalOwner === undefined || + parseTerminalOwner(checkpoint.terminalOwner) !== undefined) ) } diff --git a/src/main/daemon/terminal-history-cold-restore-info.ts b/src/main/daemon/terminal-history-cold-restore-info.ts index 71cbf1102ed..95b92d1cbdd 100644 --- a/src/main/daemon/terminal-history-cold-restore-info.ts +++ b/src/main/daemon/terminal-history-cold-restore-info.ts @@ -1,6 +1,7 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { SessionMeta } from './terminal-history-metadata' import type { TerminalModes } from './types' +import type { TerminalOwner } from '../../shared/terminal-owner' export type ColdRestoreInfo = { snapshotAnsi: string @@ -15,6 +16,7 @@ export type ColdRestoreInfo = { modes: TerminalModes pendingEscapeTailAnsi?: string lastTitle?: string + terminalOwner?: TerminalOwner } type RestoredSnapshot = { @@ -29,6 +31,7 @@ type RestoredSnapshot = { modes: TerminalModes pendingEscapeTailAnsi?: string lastTitle?: string + terminalOwner?: TerminalOwner } export function coldRestoreInfoFromSnapshot( @@ -53,6 +56,7 @@ export function coldRestoreInfoFromSnapshot( ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), - ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}) + ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}), + ...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {}) } } diff --git a/src/main/daemon/terminal-host-concurrent-create.test.ts b/src/main/daemon/terminal-host-concurrent-create.test.ts index 8e404147e76..aa7b9fda956 100644 --- a/src/main/daemon/terminal-host-concurrent-create.test.ts +++ b/src/main/daemon/terminal-host-concurrent-create.test.ts @@ -4,22 +4,42 @@ import { TerminalHost } from './terminal-host' vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) -function mockSubprocess(): SubprocessHandle { +type MockSubprocess = SubprocessHandle & { + emitData: (data: string) => void + emitExit: (code: number) => void +} + +function mockSubprocess( + options: { confirmShellForeground?: () => Promise } = {} +): MockSubprocess { + let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null return { pid: 4242, getForegroundProcess: vi.fn(() => null), + ...(options.confirmShellForeground + ? { confirmShellForeground: options.confirmShellForeground } + : {}), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 1)), forceKill: vi.fn(() => onExitCb?.(137)), signal: vi.fn(), - onData: vi.fn(), + terminateOwnedTree: () => 'unavailable' as const, + onData(cb) { + onDataCb = cb + }, onExit(cb) { onExitCb = cb }, - dispose: vi.fn() - } as unknown as SubprocessHandle + dispose: vi.fn(), + emitData(data) { + onDataCb?.(data) + }, + emitExit(code) { + onExitCb?.(code) + } + } as MockSubprocess } const streamClient = { onData: vi.fn(), onExit: vi.fn() } @@ -53,6 +73,28 @@ describe('concurrent createOrAttach across the async spawn', () => { await host.dispose() }) + it('attaches immediately while an ownership proof is still in flight', async () => { + // The proof never resolves: attach must not wait on it. The viewer takes + // the pre-reset snapshot and the in-stream reset heals its first frame. + const first = mockSubprocess({ + confirmShellForeground: vi.fn(() => new Promise(() => {})) + }) + const spawnSubprocess = vi + .fn<() => Promise>() + .mockResolvedValueOnce(first) + .mockImplementation(async () => mockSubprocess()) + const host = new TerminalHost({ spawnSubprocess }) + await host.createOrAttach(createOptions('settle-free')) + first.emitData('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(first.confirmShellForeground).toHaveBeenCalledOnce()) + + await expect(host.createOrAttach(createOptions('settle-free'))).resolves.toMatchObject({ + isNew: false + }) + expect(spawnSubprocess).toHaveBeenCalledOnce() + await host.dispose() + }) + it('lets the next caller spawn after a failed spawn releases the gate', async () => { const spawnSubprocess = vi .fn<() => Promise>() diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 1cb2a75f232..24f6cc2e867 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -38,6 +38,12 @@ export async function createOrAttachTerminalSession( throw new SessionNotFoundError(opts.sessionId) } + // Why no ownership settle here: attach is synchronous by contract. A viewer + // connecting inside an in-flight recovery proof (~100ms) gets the pre-reset + // snapshot and the injected reset arrives in-order over its stream — a + // self-healing first frame. Waiting instead created a race window (cancel, + // exit, kill during the await) that produced repeated regressions. Checkpoint + // readers that need a settled owner use getSettledSnapshot. if (existing && existing.isAlive && !existing.isTerminating) { const snapshot = existing.getSnapshot() existing.detachAllClients() diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index c8a57715b91..f85980b5453 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -233,6 +233,17 @@ export class TerminalHost { return session.confirmForegroundProcess() } + async confirmShellForeground(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (session?.isAlive !== true) { + return false + } + const confirmed = await session.confirmShellForeground() + // Why the recheck: proof for a session that exited or was replaced during + // the await is stale; the caller would bind it to the successor's stream. + return confirmed && this.sessions.get(sessionId) === session && session.isAlive + } + clearScrollback(sessionId: string): void { this.getAliveSession(sessionId).clearScrollback() } @@ -246,6 +257,21 @@ export class TerminalHost { return session.getSnapshot(opts) } + async getSettledSnapshot( + sessionId: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return null + } + await session.settleShellOwnershipConfirmation() + // Why no liveness recheck: the sync path returned the pre-exit snapshot when + // a session died a beat after the call; a disposal during the settle yields + // null naturally from the plane's own guard. + return session.getSnapshot(opts) + } + // Why: scan-authority handoff seed (null-not-throw like getSnapshot) — emulator's dangling incomplete escape at the stream position. getPartialEscapeTailAnsi(sessionId: string): string { const session = this.sessions.get(sessionId) diff --git a/src/main/daemon/terminal-shell-clean-exit-confirmation.ts b/src/main/daemon/terminal-shell-clean-exit-confirmation.ts new file mode 100644 index 00000000000..11b0fc76bfc --- /dev/null +++ b/src/main/daemon/terminal-shell-clean-exit-confirmation.ts @@ -0,0 +1,100 @@ +import type { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' + +/** + * Async ownership proof for clean alternate-screen exits: no pause, no + * injection — a confirmed proof only marks the scanner's owner, guarded by the + * generation captured at the candidate. Every attempt retires at the deadline + * so a proof that never settles cannot wedge later candidates, and a retired + * attempt's late verdict is inert. + */ +export class TerminalShellCleanExitConfirmation { + private inFlightState = false + private attempt = 0 + private waiters: (() => void)[] = [] + private latestCandidateGeneration: number | undefined + + constructor( + private readonly opts: { + scanner: TerminalShellLifecycleScanner + confirmShellForeground: () => Promise + deadlineMs: () => number + isDisposed: () => boolean + isAlive: () => boolean + } + ) {} + + get inFlight(): boolean { + return this.inFlightState + } + + waitSettled(): Promise { + return new Promise((resolve) => this.waiters.push(resolve)) + } + + drainWaiters(): void { + const waiters = this.waiters + this.waiters = [] + for (const waiter of waiters) { + waiter() + } + } + + start(generation: number): void { + this.latestCandidateGeneration = generation + if (this.inFlightState || this.opts.isDisposed()) { + return + } + this.inFlightState = true + const requested = generation + const attempt = ++this.attempt + // Why the deadline: a proof that never settles must not wedge the in-flight + // flag for the session's life, silently ignoring every later candidate. + const deadline = setTimeout(() => this.retire(attempt, requested), this.opts.deadlineMs()) + deadline.unref?.() + // Why the guard: the callback is injected; a synchronous throw must not + // strand the in-flight flag. + let proof: Promise + try { + proof = this.opts.confirmShellForeground() + } catch { + proof = Promise.resolve(false) + } + void proof + .then((confirmed) => { + // A retired attempt's late verdict is inert: an unsettled proof already + // read as no owner, and flipping it afterwards would race fresh bytes. + if ( + attempt === this.attempt && + confirmed && + !this.opts.isDisposed() && + this.opts.isAlive() + ) { + this.opts.scanner.trySetOwner(requested) + } + }) + .catch(() => {}) + .finally(() => { + clearTimeout(deadline) + this.retire(attempt, requested) + }) + } + + private retire(attempt: number, requested: number): void { + if (attempt !== this.attempt) { + return + } + this.attempt += 1 + this.inFlightState = false + const latest = this.latestCandidateGeneration + if ( + latest !== undefined && + latest !== requested && + latest === this.opts.scanner.generation && + !this.opts.isDisposed() + ) { + // One superseding candidate can still be current; stale ones are not retried. + this.start(latest) + } + this.drainWaiters() + } +} diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts new file mode 100644 index 00000000000..6d5fa662187 --- /dev/null +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest' +import { POST_REPLAY_DEAD_TUI_RESET } from '../../shared/terminal-mode-reset-profiles' +import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' + +const ENTER_ALT = '\x1b[?1049h' +const LEAVE_ALT = '\x1b[?1049l' + +describe('TerminalShellLifecycleScanner', () => { + describe('unclean death trigger', () => { + it('stops at the OSC 133;D terminator while the alternate screen is up', () => { + const scanner = new TerminalShellLifecycleScanner() + const chunk = `${ENTER_ALT}TUI\x1b]133;D;137\x07shell-marker` + + const events = scanner.scan(chunk) + + expect(events.uncleanDeathTriggerEnd).toBe(chunk.indexOf('shell-marker')) + expect(chunk.slice(events.uncleanDeathTriggerEnd)).toBe('shell-marker') + expect(scanner.isAlternateScreenActive).toBe(true) + expect(scanner.owner).toBeUndefined() + expect(events.cleanExitCandidate).toBeUndefined() + }) + + it('does not consume bytes past the trigger', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x07shell-marker`) + + expect(scanner.scan('shell-marker')).toEqual({}) + }) + + it('carries no partial sequence out of the trigger', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x07\x1b[?10`) + const generation = scanner.generation + + // A leaked tail would compose with this into `\x1b[?1049h` and revoke again. + expect(scanner.scan('49h')).toEqual({}) + expect(scanner.generation).toBe(generation) + }) + + it('reports a chunk-local index when the sequence spans three chunks', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan('\x1b[?10')).toEqual({}) + expect(scanner.scan('49hTUI\x1b]133;D;1')).toEqual({}) + const events = scanner.scan('37\x07tail') + + expect(events.uncleanDeathTriggerEnd).toBe('37\x07'.length) + expect('37\x07tail'.slice(events.uncleanDeathTriggerEnd)).toBe('tail') + expect(scanner.owner).toBeUndefined() + }) + + it('accepts an ST-terminated OSC', () => { + const scanner = new TerminalShellLifecycleScanner() + const chunk = `${ENTER_ALT}TUI\x1b]133;D;137\x1b\\rest` + + const events = scanner.scan(chunk) + + expect(events.uncleanDeathTriggerEnd).toBe(chunk.indexOf('rest')) + }) + + it('accepts an ST split across chunks', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x1b`)).toEqual({}) + const events = scanner.scan('\\rest') + + expect(events.uncleanDeathTriggerEnd).toBe(1) + }) + + it('fires again on the re-fed remainder', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x07REMAINDER`) + + // The remainder starts a fresh TUI that dies the same way. + expect(scanner.scan(LEAVE_ALT)).toEqual({}) + const again = scanner.scan(`${ENTER_ALT}AGAIN\x1b]133;D;9\x07`) + + expect(again.uncleanDeathTriggerEnd).toBe(`${ENTER_ALT}AGAIN\x1b]133;D;9\x07`.length) + }) + }) + + describe('clean exit candidates', () => { + it('offers ownership after a TUI leaves the alternate screen and the command ends', () => { + const scanner = new TerminalShellLifecycleScanner() + + const events = scanner.scan(`${ENTER_ALT}TUI${LEAVE_ALT}\x1b]133;D;0\x07`) + + expect(events.uncleanDeathTriggerEnd).toBeUndefined() + const candidate = events.cleanExitCandidate + expect(candidate).toBeDefined() + expect(scanner.owner).toBeUndefined() + expect(scanner.trySetOwner(candidate!.generation)).toBe(true) + expect(scanner.owner).toBe('shell') + expect(scanner.isAlternateScreenActive).toBe(false) + }) + + it('invalidates the candidate when output arrives before the owner is set', () => { + const scanner = new TerminalShellLifecycleScanner() + const candidate = scanner.scan( + `${ENTER_ALT}TUI${LEAVE_ALT}\x1b]133;D;0\x07` + ).cleanExitCandidate + + scanner.scan('\x1b]133;C\x07') + + expect(scanner.trySetOwner(candidate!.generation)).toBe(false) + expect(scanner.owner).toBeUndefined() + }) + + it('does not let a candidate survive a TUI started later in the same chunk', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI${LEAVE_ALT}`) + + const candidate = scanner.scan(`\x1b]133;D;0\x07${ENTER_ALT}LIVE`).cleanExitCandidate + + expect(candidate).toBeDefined() + expect(scanner.trySetOwner(candidate!.generation)).toBe(false) + expect(scanner.owner).toBeUndefined() + }) + + it('emits nothing for a command that never entered the alternate screen', () => { + const scanner = new TerminalShellLifecycleScanner() + + const events = scanner.scan(`\x1b]133;D;0\x07${ENTER_ALT}LIVE`) + + expect(events).toEqual({}) + }) + + it('forgets the alternate-screen visit once a new command starts', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI${LEAVE_ALT}`) + + scanner.scan('\x1b]133;C\x07') + + expect(scanner.scan('\x1b]133;D;0\x07')).toEqual({}) + }) + + it('emits no candidate for an ordinary command completion', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan('\x1b]133;D;0\x07')).toEqual({}) + }) + + it('keeps only the last candidate of a chunk', () => { + const scanner = new TerminalShellLifecycleScanner() + + const events = scanner.scan( + `${ENTER_ALT}a${LEAVE_ALT}\x1b]133;D;0\x07${ENTER_ALT}b${LEAVE_ALT}\x1b]133;D;0\x07` + ) + + expect(events.cleanExitCandidate?.generation).toBe(scanner.generation) + expect(scanner.trySetOwner(events.cleanExitCandidate!.generation)).toBe(true) + }) + }) + + describe('revocation', () => { + it('revokes on a mouse-only DECSET', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + + scanner.scan('\x1b[?1003h') + + expect(scanner.owner).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(false) + }) + + it('revokes on a kitty keyboard push', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + + scanner.scan('\x1b[>1u') + + expect(scanner.owner).toBeUndefined() + }) + + it('ignores the kitty pop and clear forms we inject ourselves', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan('\x1b[<99u\x1b[=0u') + + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + }) + + it('revokes on every command boundary, not just ones touching the alternate screen', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan('\x1b]133;D;0\x07') + + expect(scanner.owner).toBeUndefined() + expect(scanner.generation).toBe(generation + 1) + + scanner.trySetOwner(scanner.generation) + scanner.scan('\x1b]133;C\x07') + + expect(scanner.owner).toBeUndefined() + }) + + it('revokes and clears alternate-screen state on RIS', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(ENTER_ALT) + scanner.seedOwner('shell') + + scanner.scan('\x1bc') + + expect(scanner.owner).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(false) + // The cleared flag means a later D is a plain completion, not a clean exit. + expect(scanner.scan('\x1b]133;D;0\x07')).toEqual({}) + }) + + it('recognizes the 8-bit CSI introducer', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + + scanner.scan('\x9b?1049h') + + expect(scanner.owner).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(true) + }) + + it('does not revoke on DECRST leaving the alternate screen', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan(LEAVE_ALT) + + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + }) + }) + + describe('inert input', () => { + it('ignores plain and colored output', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + expect(scanner.scan('hello world\r\n')).toEqual({}) + expect(scanner.scan('\x1b[31mred\x1b[0m')).toEqual({}) + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + }) + + it('leaves ownership intact across our own dead-TUI reset', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan(POST_REPLAY_DEAD_TUI_RESET) + + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + expect(scanner.isAlternateScreenActive).toBe(false) + }) + + it('drops an oversized carried tail instead of growing without bound', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan(`\x1b]133;${'x'.repeat(5000)}`)).toEqual({}) + expect(scanner.scan('\x07')).toEqual({}) + }) + }) +}) + +describe('unclean trigger arming', () => { + it('fires once per alternate-screen occupancy and re-arms only on a fresh entry', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan('\x1b[?1049hTUI\x1b]133;D;137\x07').uncleanDeathTriggerEnd).toBeDefined() + // Refuted path: no reset scanned, alt still active — a later prompt's D must not re-trigger. + const second = scanner.scan('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07') + expect(second.uncleanDeathTriggerEnd).toBeUndefined() + expect(second.cleanExitCandidate).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(true) + + expect( + scanner.scan('\x1b]133;C\x07\x1b[?1049hAGAIN\x1b]133;D;9\x07').uncleanDeathTriggerEnd + ).toBeDefined() + }) +}) diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.ts new file mode 100644 index 00000000000..97a4a121dce --- /dev/null +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.ts @@ -0,0 +1,223 @@ +import type { TerminalOwner } from '../../shared/terminal-owner' + +// Why: PTY/SSH chunks can split a long combined DECSET or an OSC 133 payload +// before its terminator. Keep parser state far beyond normal sequence lengths +// while still bounding memory. +const SCAN_TAIL_LIMIT = 4096 + +// Any mode a full-screen or mouse-driven app arms: once one appears the shell +// is no longer the thing writing to this pane. +const TUI_MODE_ENABLES = new Set([47, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 1016, 1047, 1049]) +const ALTERNATE_SCREEN_MODES = new Set([47, 1047, 1049]) + +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const LIFECYCLE_OSC = /\x1b\]133;([^\x07\x1b]*)(?:\x07|\x1b\\)/ +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const PRIVATE_MODE = /\x1b\[\?([0-9;]*)([hl])|\x9b\?([0-9;]*)([hl])/ +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const KITTY_KEYBOARD = /\x1b\[([>=])([0-9;]*)u|\x9b([>=])([0-9;]*)u/ +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const FULL_RESET = /\x1bc/ + +// Groups: 1 OSC 133 payload, 2/3 and 4/5 private-mode params + final, 6/7 and 8/9 kitty prefix + params. +const SEQUENCE_RE = new RegExp( + [LIFECYCLE_OSC, PRIVATE_MODE, KITTY_KEYBOARD, FULL_RESET].map((re) => re.source).join('|'), + 'g' +) + +export type ShellLifecycleScanEvents = { + /** + * Index into the chunk just handed to `scan()`, one past the terminator of an + * OSC 133;D that arrived while the alternate screen was still active. Bytes at + * and after this index were NOT consumed; the caller re-feeds them. + */ + uncleanDeathTriggerEnd?: number + /** An OSC 133;D closed a command that had entered the alternate screen and left it cleanly. */ + cleanExitCandidate?: { generation: number } +} + +/** + * Streaming, chunk-split-safe scanner of raw PTY output that tracks which side + * owns the stream. Callers must feed `scan()` the same bytes the terminal + * parsed, in order. + * + * Ownership is generation-guarded rather than a plain flag: an async consumer + * that decides "the shell owns this pane" from an event must prove no revoking + * sequence slipped in between, which `trySetOwner()` checks. + */ +export class TerminalShellLifecycleScanner { + private scanTail = '' + private ownerState: TerminalOwner | undefined + private generationState = 0 + private altActive = false + private commandEnteredAlternateScreen = false + // Why one-shot: a refuted proof leaves altActive true (no reset was ever + // scanned), and without disarming every later prompt's D would re-open a + // full pause-and-inspect episode. Only a fresh alternate-screen entry re-arms. + private uncleanTriggerArmed = false + + get owner(): TerminalOwner | undefined { + return this.ownerState + } + + get generation(): number { + return this.generationState + } + + get isAlternateScreenActive(): boolean { + return this.altActive + } + + trySetOwner(generation: number): boolean { + if (generation !== this.generationState) { + return false + } + this.ownerState = 'shell' + return true + } + + seedOwner(owner: TerminalOwner | undefined, opts: { alternateScreen?: boolean } = {}): void { + this.generationState += 1 + this.ownerState = owner + if (opts.alternateScreen !== undefined) { + // Why seeded mode state matters: restored bytes bypass scan(), so without + // this a mirror seeded mid-TUI never arms its unclean-death trigger and + // the whole occupancy loses recovery. + this.altActive = opts.alternateScreen + this.uncleanTriggerArmed = opts.alternateScreen + this.commandEnteredAlternateScreen = opts.alternateScreen + } + } + + scan(chunk: string): ShellLifecycleScanEvents { + const events: ShellLifecycleScanEvents = {} + // Why the pre-filter: this runs per chunk for every session; a flood chunk + // with no escape introducer must not pay the regex pass. Split sequences + // stay correct because a partial one always left a non-empty scanTail. + if (this.scanTail.length === 0 && !chunk.includes('\x1b') && !chunk.includes('\x9b')) { + return events + } + const previousTailLength = this.scanTail.length + const input = previousTailLength === 0 ? chunk : this.scanTail + chunk + this.scanTail = this.extractScanTail(input) + // The shared regex is safe because scan() is synchronous and never re-enters. + SEQUENCE_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = SEQUENCE_RE.exec(input)) !== null) { + const oscPayload = match[1] + if (match[0] === '\x1bc') { + this.revoke() + this.altActive = false + this.commandEnteredAlternateScreen = false + this.uncleanTriggerArmed = false + continue + } + if (oscPayload !== undefined) { + const marker = oscPayload[0] + if (marker === 'C') { + this.revoke() + this.commandEnteredAlternateScreen = false + continue + } + if (marker !== 'D') { + continue + } + // An alternate screen still up at command-finished means the app died + // without its own teardown; the caller must repair before more bytes land. + const uncleanDeath = this.altActive && this.uncleanTriggerArmed + const cleanExit = !uncleanDeath && this.commandEnteredAlternateScreen && !this.altActive + this.revoke() + this.commandEnteredAlternateScreen = false + if (uncleanDeath) { + this.uncleanTriggerArmed = false + this.scanTail = '' + // Why the clamp: a complete OSC can never sit wholly inside the + // carried tail (extractScanTail keeps incomplete ones only), so this + // is > 0 by construction — the clamp only guards that invariant + // against a future loosening of the tail extractor. + events.uncleanDeathTriggerEnd = Math.max( + 0, + match.index + match[0].length - previousTailLength + ) + return events + } + if (cleanExit) { + events.cleanExitCandidate = { generation: this.generationState } + } + continue + } + const kittyPrefix = match[6] ?? match[8] + if (kittyPrefix !== undefined) { + // `CSI < n u` (pop) and `CSI = 0 u` (clear) appear in our own injected + // reset, so only a push of real flags counts as a new owner. + const first = Number((match[7] ?? match[9] ?? '').split(';')[0]) + if (Number.isInteger(first) && first > 0) { + this.revoke() + } + continue + } + const enabled = (match[3] ?? match[5]) === 'h' + for (const rawParam of (match[2] ?? match[4] ?? '').split(';')) { + if (rawParam === '') { + continue + } + const param = Number(rawParam) + if (!Number.isInteger(param)) { + continue + } + if (enabled && TUI_MODE_ENABLES.has(param)) { + this.revoke() + } + if (ALTERNATE_SCREEN_MODES.has(param)) { + this.altActive = enabled + if (enabled) { + this.commandEnteredAlternateScreen = true + this.uncleanTriggerArmed = true + } + } + } + } + return events + } + + private revoke(): void { + this.generationState += 1 + this.ownerState = undefined + } + + private extractScanTail(input: string): string { + const oscStart = input.lastIndexOf('\x1b]') + if (oscStart !== -1 && isIncompleteLifecycleOsc(input.slice(oscStart))) { + return boundTail(input.slice(oscStart)) + } + const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) + if (start === -1) { + return '' + } + const tail = boundTail(input.slice(start)) + if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b' || tail === '') { + return tail + } + const params = tail.startsWith('\x1b[') + ? tail.slice(2) + : tail.startsWith('\x9b') + ? tail.slice(1) + : undefined + return params !== undefined && /^[?>=][0-9;]*$/.test(params) ? tail : '' + } +} + +function boundTail(tail: string): string { + return tail.length > SCAN_TAIL_LIMIT ? '' : tail +} + +/** `tail` starts with `\x1b]`; true only while it could still become a complete OSC 133. */ +function isIncompleteLifecycleOsc(tail: string): boolean { + const body = tail.slice(2) + if (body.length < 4) { + return '133;'.startsWith(body) + } + // Payload may end mid-ST: the trailing ESC is the first half of `\x1b\`. + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + return body.startsWith('133;') && /^[^\x07\x1b]*\x1b?$/.test(body.slice(4)) +} diff --git a/src/main/daemon/terminal-shell-recovery-barrier.test.ts b/src/main/daemon/terminal-shell-recovery-barrier.test.ts new file mode 100644 index 00000000000..5c6346e8a67 --- /dev/null +++ b/src/main/daemon/terminal-shell-recovery-barrier.test.ts @@ -0,0 +1,405 @@ +import { describe, expect, it, vi } from 'vitest' +import { POST_REPLAY_DEAD_TUI_RESET } from '../../shared/terminal-mode-reset-profiles' +import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' + +const TRIGGER = '\x1b[?1049hTUI\x1b]133;D;137\x07' + +function passthrough(data: string, rawStartSeq = 0): PtyIngressEmission { + return { data, rawStartSeq, rawEndSeq: rawStartSeq + data.length, transformed: false } +} + +function createBarrier(opts?: { + confirm?: () => Promise + isAlive?: () => boolean + maxQueuedBytes?: number + maxPendingMs?: number +}) { + const released: PtyIngressEmission[] = [] + const confirm = vi.fn(opts?.confirm ?? (async () => true)) + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: confirm, + release: (emission) => released.push(emission), + isAlive: opts?.isAlive ?? (() => true), + ...(opts?.maxQueuedBytes !== undefined ? { maxQueuedBytes: opts.maxQueuedBytes } : {}), + ...(opts?.maxPendingMs !== undefined ? { maxPendingMs: opts.maxPendingMs } : {}) + }) + return { barrier, released, confirm } +} + +describe('TerminalShellRecoveryBarrier', () => { + it('releases ordinary output untouched without inspections', () => { + const { barrier, released, confirm } = createBarrier() + const emission = passthrough('plain \x1b[31mred\x1b[0m output\r\n') + barrier.accept(emission) + + expect(released).toEqual([emission]) + expect(confirm).not.toHaveBeenCalled() + expect(barrier.getOwner()).toBeUndefined() + }) + + it('pauses at the unclean-death boundary and delivers the queued prompt after the injected reset', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released, confirm } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough(`${TRIGGER}SHELL-PROMPT`, 100)) + expect(confirm).toHaveBeenCalledTimes(1) + expect(released).toEqual([ + { data: TRIGGER, rawStartSeq: 100, rawEndSeq: 100 + TRIGGER.length, transformed: false } + ]) + + resolveConfirm?.(true) + await vi.waitFor(() => expect(released).toHaveLength(3)) + expect(released[1]).toEqual({ + data: POST_REPLAY_DEAD_TUI_RESET, + rawStartSeq: 100 + TRIGGER.length, + rawEndSeq: 100 + TRIGGER.length, + transformed: true + }) + expect(released[2]).toEqual({ + data: 'SHELL-PROMPT', + rawStartSeq: 100 + TRIGGER.length, + rawEndSeq: 100 + TRIGGER.length + 'SHELL-PROMPT'.length, + transformed: false + }) + expect(barrier.getOwner()).toBe('shell') + }) + + it('queues later emissions during an episode and preserves order', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough(TRIGGER, 0)) + barrier.accept(passthrough('late-1', TRIGGER.length)) + barrier.accept(passthrough('late-2', TRIGGER.length + 6)) + expect(released).toHaveLength(1) + + resolveConfirm?.(true) + await vi.waitFor(() => expect(released).toHaveLength(4)) + expect(released.map((emission) => emission.data)).toEqual([ + TRIGGER, + POST_REPLAY_DEAD_TUI_RESET, + 'late-1', + 'late-2' + ]) + }) + + it('flushes unmodified with no injection when the proof is refuted', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough(`${TRIGGER}nested-shell`)) + resolveConfirm?.(false) + + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'nested-shell']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('bails out on timeout and ignores a late confirmation', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)), + maxPendingMs: 20 + }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + + resolveConfirm?.(true) + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(released).toHaveLength(2) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('bails out when the queue exceeds its byte budget', () => { + const { barrier, released } = createBarrier({ + confirm: () => new Promise(() => {}), + maxQueuedBytes: 8 + }) + + barrier.accept(passthrough(TRIGGER)) + barrier.accept(passthrough('0123456789')) + + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, '0123456789']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('does not inject when the session died while the proof settled', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + let alive = true + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)), + isAlive: () => alive + }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + alive = false + resolveConfirm?.(true) + + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('handles a second unclean episode queued behind the first', async () => { + const confirms: ((confirmed: boolean) => void)[] = [] + const { barrier, released, confirm } = createBarrier({ + confirm: () => new Promise((resolve) => void confirms.push(resolve)) + }) + + barrier.accept(passthrough(`${TRIGGER}first-prompt`, 0)) + const second = `\x1b[?1049hAGAIN\x1b]133;D;9\x07second-prompt` + barrier.accept(passthrough(second, TRIGGER.length + 'first-prompt'.length)) + + confirms[0]?.(true) + await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(2)) + confirms[1]?.(true) + + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + TRIGGER, + POST_REPLAY_DEAD_TUI_RESET, + 'first-prompt', + '\x1b[?1049hAGAIN\x1b]133;D;9\x07', + POST_REPLAY_DEAD_TUI_RESET, + 'second-prompt' + ]) + ) + expect(barrier.getOwner()).toBe('shell') + }) + + it('does not re-trigger on the next command-done after an injected recovery', async () => { + const { barrier, released, confirm } = createBarrier() + barrier.accept(passthrough(`${TRIGGER}prompt`)) + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + expect(confirm).toHaveBeenCalledTimes(1) + + // Why: the injected reset's ?1049l must ground the scanner's alt state; + // a bare D at the next plain prompt would otherwise loop the repair. + barrier.accept(passthrough('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07')) + + expect(confirm).toHaveBeenCalledTimes(1) + expect(released.at(-1)?.data).toBe('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07') + }) + + it('proves clean alternate-screen exits without pausing the stream', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released, confirm } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + const emission = passthrough('\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07PROMPT') + + barrier.accept(emission) + expect(released).toEqual([emission]) + expect(confirm).toHaveBeenCalledTimes(1) + expect(barrier.getOwner()).toBeUndefined() + + resolveConfirm?.(true) + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + }) + + it('rejects a clean-exit proof superseded by a later command start', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough('\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07')) + barrier.accept(passthrough('\x1b]133;C\x07LIVE-TUI')) + resolveConfirm?.(true) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(barrier.getOwner()).toBeUndefined() + }) + + it('revokes proven ownership when a later TUI arms modes', async () => { + const { barrier } = createBarrier() + barrier.accept(passthrough(TRIGGER)) + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + + barrier.accept(passthrough('\x1b[?1003hLIVE')) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('resolves idle() at each episode boundary', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + await expect(barrier.idle()).resolves.toBeUndefined() + + barrier.accept(passthrough(TRIGGER)) + let idled = false + const idle = barrier.idle().then(() => { + idled = true + }) + await Promise.resolve() + expect(idled).toBe(false) + + resolveConfirm?.(true) + await idle + }) + + it('skips the episode for a transformed emission it cannot split', () => { + const { barrier, released, confirm } = createBarrier() + const emission: PtyIngressEmission = { + data: `${TRIGGER}prompt`, + rawStartSeq: 0, + rawEndSeq: 4, + transformed: true + } + barrier.accept(emission) + + expect(released).toEqual([emission]) + expect(confirm).not.toHaveBeenCalled() + expect(barrier.getOwner()).toBeUndefined() + }) + + it('recognizes a trigger split across emissions and splits the completing one', async () => { + const { barrier, released } = createBarrier() + const head = '\x1b[?1049hTUI\x1b]133;D;1' + const tail = '37\x07PROMPT' + barrier.accept(passthrough(head, 0)) + barrier.accept(passthrough(tail, head.length)) + + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + head, + '37\x07', + POST_REPLAY_DEAD_TUI_RESET, + 'PROMPT' + ]) + ) + expect(released[1]).toMatchObject({ rawStartSeq: head.length, rawEndSeq: head.length + 3 }) + expect(released[3]).toMatchObject({ + rawStartSeq: head.length + 3, + rawEndSeq: head.length + tail.length + }) + }) + + it('survives a throwing downstream client without losing the queue or wedging attach', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const released: PtyIngressEmission[] = [] + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: () => new Promise((resolve) => void (resolveConfirm = resolve)), + release: (emission) => { + if (emission.data === 'poison') { + throw new Error('client transport died') + } + released.push(emission) + }, + isAlive: () => true + }) + + barrier.accept(passthrough(TRIGGER, 0)) + barrier.accept(passthrough('poison', TRIGGER.length)) + barrier.accept(passthrough('after-poison', TRIGGER.length + 6)) + const settled = barrier.awaitProofSettled() + resolveConfirm?.(true) + + await settled + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + TRIGGER, + POST_REPLAY_DEAD_TUI_RESET, + 'after-poison' + ]) + ) + await expect(barrier.idle()).resolves.toBeUndefined() + }) + + it('opens at most one episode per alternate-screen occupancy after a refuted proof', async () => { + const { barrier, released, confirm } = createBarrier({ confirm: async () => false }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(confirm).toHaveBeenCalledTimes(1) + + // Why: the refuted path never scans a reset, so alt stays active — later + // ordinary prompts must not each re-open a pause-and-inspect episode. + for (let index = 0; index < 5; index += 1) { + const prompt = passthrough(`\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07`) + barrier.accept(prompt) + expect(released.at(-1)).toBe(prompt) + } + expect(confirm).toHaveBeenCalledTimes(1) + + // A fresh alternate-screen entry re-arms recovery. + barrier.accept(passthrough(`\x1b]133;C\x07\x1b[?1049hAGAIN\x1b]133;D;9\x07`)) + expect(confirm).toHaveBeenCalledTimes(2) + }) + + it('bounds awaitProofSettled by its own deadline when a clean-exit proof hangs', async () => { + const { barrier } = createBarrier({ + confirm: () => new Promise(() => {}), + maxPendingMs: 20 + }) + + barrier.accept(passthrough('\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07')) + const start = Date.now() + await barrier.awaitProofSettled() + expect(Date.now() - start).toBeLessThan(500) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('still opens the episode when the trigger-carrying head release throws', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const released: PtyIngressEmission[] = [] + const confirm = vi.fn(() => new Promise((resolve) => void (resolveConfirm = resolve))) + let headThrown = false + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: confirm, + release: (emission) => { + if (!headThrown && emission.data === TRIGGER) { + headThrown = true + throw new Error('client transport died mid-broadcast') + } + released.push(emission) + }, + isAlive: () => true + }) + + barrier.accept(passthrough(`${TRIGGER}SHELL-PROMPT`)) + expect(confirm).toHaveBeenCalledTimes(1) + resolveConfirm?.(true) + + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + POST_REPLAY_DEAD_TUI_RESET, + 'SHELL-PROMPT' + ]) + ) + expect(barrier.getOwner()).toBe('shell') + }) + + it('flushes a pending episode synchronously for a teardown snapshot', () => { + const { barrier, released } = createBarrier({ confirm: () => new Promise(() => {}) }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER]) + + barrier.flushPending() + + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('drops queued output only on disposal', () => { + const { barrier, released } = createBarrier({ confirm: () => new Promise(() => {}) }) + barrier.accept(passthrough(`${TRIGGER}prompt`)) + barrier.dispose() + + expect(released.map((emission) => emission.data)).toEqual([TRIGGER]) + barrier.accept(passthrough('after-dispose')) + expect(released).toHaveLength(1) + }) +}) diff --git a/src/main/daemon/terminal-shell-recovery-barrier.ts b/src/main/daemon/terminal-shell-recovery-barrier.ts new file mode 100644 index 00000000000..7f4a94e9861 --- /dev/null +++ b/src/main/daemon/terminal-shell-recovery-barrier.ts @@ -0,0 +1,338 @@ +import { POST_REPLAY_DEAD_TUI_RESET } from '../../shared/terminal-mode-reset-profiles' +import { TerminalShellCleanExitConfirmation } from './terminal-shell-clean-exit-confirmation' +import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' +import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' +import type { TerminalOwner } from '../../shared/terminal-owner' + +// Why bounded: a pause only bridges one process proof (~100ms measured p95); a +// flood or hang means the trigger misfired, so bail out and flush unmodified. +const MAX_QUEUED_BYTES = 262_144 +const MAX_PENDING_MS = 750 + +export type TerminalShellRecoveryBarrierOptions = { + /** Fresh execution-host proof that the spawned shell owns the PTY foreground. */ + confirmShellForeground: () => Promise + /** Ordered downstream sink (emulator write + record + client broadcast). */ + release: (emission: PtyIngressEmission) => void + isAlive: () => boolean + maxQueuedBytes?: number + maxPendingMs?: number +} + +/** + * Ordered output barrier for dead-TUI mode recovery. Sits between startup + * ingress and the output plane. When a shell-integration command-done marker + * (OSC 133;D) arrives while the alternate screen is still active, the stream + * pauses at that exact byte boundary, the execution host proves the shell owns + * the PTY foreground, and on proof a mode reset is injected as in-stream output + * so every downstream consumer (host emulator, mirrors, attached renderers, + * history) converges — and the queued shell prompt then paints onto the normal + * buffer instead of the discarded alternate screen. Any failure (refuted proof, + * timeout, overflow, death, disposal) flushes the queue unmodified, preserving + * incumbent behavior. Clean alternate-screen exits prove ownership without + * pausing: the model needs no correction, only snapshot metadata. + */ +export class TerminalShellRecoveryBarrier { + private readonly scanner = new TerminalShellLifecycleScanner() + private readonly confirmShellForeground: () => Promise + private readonly releaseDownstream: (emission: PtyIngressEmission) => void + private readonly isAlive: () => boolean + private readonly maxQueuedBytes: number + private readonly maxPendingMs: number + + private queue: PtyIngressEmission[] = [] + private queuedBytes = 0 + private pending = false + private pendingGeneration = 0 + private pendingRawSeq = 0 + private pendingEpisode = 0 + private bailTimer: ReturnType | null = null + private idleWaiters: (() => void)[] = [] + private readonly cleanExit: TerminalShellCleanExitConfirmation + private disposed = false + + constructor(opts: TerminalShellRecoveryBarrierOptions) { + this.confirmShellForeground = opts.confirmShellForeground + this.releaseDownstream = opts.release + this.isAlive = opts.isAlive + this.maxQueuedBytes = opts.maxQueuedBytes ?? MAX_QUEUED_BYTES + this.maxPendingMs = opts.maxPendingMs ?? MAX_PENDING_MS + this.cleanExit = new TerminalShellCleanExitConfirmation({ + scanner: this.scanner, + confirmShellForeground: opts.confirmShellForeground, + deadlineMs: () => this.maxPendingMs, + isDisposed: () => this.disposed, + isAlive: opts.isAlive + }) + } + + accept(emission: PtyIngressEmission): void { + if (this.disposed) { + return + } + if (this.pending) { + this.enqueue(emission) + return + } + this.scanAndRelease(emission) + } + + getOwner(): TerminalOwner | undefined { + return this.scanner.owner + } + + /** Answers a paired runtime's ownership question from the barrier's settled + * state. The barrier scans bytes before any consumer receives them, so its + * verdict is never behind the caller's parse position — a fresh process + * inspection here would prove nothing the barrier didn't already. */ + async confirmOwnerSettled(): Promise { + if (this.scanner.owner === 'shell') { + return true + } + await this.awaitProofSettled() + return this.scanner.owner === 'shell' + } + + /** Synchronous bail for teardown: resolves the current episode as refuted so + * the queued bytes reach the emulator and records before a final snapshot. + * Queue replay can re-enter pending on a nested trigger, so drain to + * completion — bounded, because a pathological stream must not spin teardown. */ + flushPending(): void { + for (let guard = 0; this.pending && guard < 16; guard += 1) { + this.finishPending(this.pendingEpisode, false) + } + if (!this.pending) { + return + } + // Pathological episode storm: stop rescanning but never drop bytes. The + // scanner's model is stale from here on — a checkpoint after this may + // publish a stale owner, acceptable only past a >16-nested-episode storm. + const queued = this.queue + this.queue = [] + this.queuedBytes = 0 + this.pending = false + if (this.bailTimer) { + clearTimeout(this.bailTimer) + this.bailTimer = null + } + for (const emission of queued) { + try { + this.releaseDownstream(emission) + } catch { + // One throwing client must not cost the rest of the queue its delivery. + } + } + this.resolveIdleWaiters() + } + + /** Resolves when the current recovery episode (if any) has settled. Bounded + * by the bail timer; resolves at each episode boundary so a hostile stream + * of back-to-back episodes cannot starve an attach. */ + idle(): Promise { + if (!this.pending) { + return Promise.resolve() + } + return new Promise((resolve) => this.idleWaiters.push(resolve)) + } + + /** Like idle(), but also waits out an in-flight clean-exit proof so snapshot + * and RPC readers see a settled owner. A generation advance past the entry + * point ends the wait (that proof is stale), and the wait itself is bounded + * by the barrier's own budget — attach latency must never inherit a slow + * out-of-process inspection; an unsettled proof just reads as no owner. */ + async awaitProofSettled(): Promise { + const target = this.scanner.generation + let deadlineHit = false + const deadline = setTimeout(() => { + deadlineHit = true + this.resolveIdleWaiters() + this.cleanExit.drainWaiters() + }, this.maxPendingMs) + deadline.unref?.() + try { + while ( + (this.pending || this.cleanExit.inFlight) && + this.scanner.generation <= target && + !this.disposed && + !deadlineHit + ) { + await (this.pending ? this.idle() : this.cleanExit.waitSettled()) + } + } finally { + clearTimeout(deadline) + } + } + + dispose(): void { + this.disposed = true + if (this.bailTimer) { + clearTimeout(this.bailTimer) + this.bailTimer = null + } + // Callers flush first (exit, dispose, prepareForFinalSnapshot); anything + // still queued here has no live downstream left to receive it. + this.queue = [] + this.queuedBytes = 0 + this.pending = false + this.resolveIdleWaiters() + this.cleanExit.drainWaiters() + } + + private scanAndRelease(emission: PtyIngressEmission): void { + const events = this.scanner.scan(emission.data) + if (events.cleanExitCandidate) { + this.cleanExit.start(events.cleanExitCandidate.generation) + } + const end = events.uncleanDeathTriggerEnd + if (end === undefined) { + this.releaseDownstream(emission) + return + } + const splittable = + !emission.transformed && emission.rawEndSeq - emission.rawStartSeq === emission.data.length + if (!splittable) { + // Why skip the episode: the raw-seq boundary inside a transformed emission + // cannot be reconstructed, so release everything and keep the scanner + // honest about the remainder — incumbent behavior for this rare corner. + try { + this.releaseDownstream(emission) + } catch { + // Why swallowed: emit already wrote and recorded before a client threw; + // the scanner must still advance past the remainder below. + } + this.consumeForStateOnly(emission.data.slice(end)) + return + } + const splitSeq = emission.rawStartSeq + end + try { + this.releaseDownstream({ + data: emission.data.slice(0, end), + rawStartSeq: emission.rawStartSeq, + rawEndSeq: splitSeq, + transformed: false + }) + } catch { + // Why swallowed: the emulator and records already took the head inside + // emit before a client's broadcast threw; aborting here would cost the + // post-boundary prompt its entire recovery episode. + } + this.enterPending(splitSeq) + if (end < emission.data.length) { + this.enqueue({ + data: emission.data.slice(end), + rawStartSeq: splitSeq, + rawEndSeq: emission.rawEndSeq, + transformed: false + }) + } + } + + private consumeForStateOnly(data: string): void { + let rest = data + while (rest.length > 0) { + const events = this.scanner.scan(rest) + if (events.cleanExitCandidate) { + this.cleanExit.start(events.cleanExitCandidate.generation) + } + if (events.uncleanDeathTriggerEnd === undefined) { + return + } + rest = rest.slice(events.uncleanDeathTriggerEnd) + } + } + + private enterPending(rawSeq: number): void { + this.pending = true + this.pendingEpisode += 1 + this.pendingGeneration = this.scanner.generation + this.pendingRawSeq = rawSeq + const episode = this.pendingEpisode + this.bailTimer = setTimeout(() => this.finishPending(episode, false), this.maxPendingMs) + this.bailTimer.unref?.() + // Why the guard: the callback is injected; a synchronous throw must not + // escape after pending flipped true and strand the episode until the bail. + let proof: Promise + try { + proof = this.confirmShellForeground() + } catch { + proof = Promise.resolve(false) + } + // Why the two-step then: a throw escaping finishPending must not re-enter + // it through a catch arm as a phantom refutation. + void proof + .then( + (confirmed) => confirmed, + () => false + ) + .then((confirmed) => this.finishPending(episode, confirmed)) + } + + private finishPending(episode: number, confirmed: boolean): void { + if (!this.pending || this.disposed || episode !== this.pendingEpisode) { + return + } + this.pending = false + if (this.bailTimer) { + clearTimeout(this.bailTimer) + this.bailTimer = null + } + const queued = this.queue + this.queue = [] + this.queuedBytes = 0 + try { + if (confirmed && this.isAlive()) { + // Scanned before release so alt-state stays honest; the reset bytes are + // deliberately inert for ownership (no OSC 133, no TUI mode enables). + this.scanner.scan(POST_REPLAY_DEAD_TUI_RESET) + try { + this.releaseDownstream({ + data: POST_REPLAY_DEAD_TUI_RESET, + rawStartSeq: this.pendingRawSeq, + rawEndSeq: this.pendingRawSeq, + transformed: true + }) + } catch { + // Why swallowed: a throwing downstream client must not strand the + // queued prompt bytes below. + } + this.scanner.trySetOwner(this.pendingGeneration) + } + for (let index = 0; index < queued.length; index += 1) { + if (this.disposed) { + break + } + if (this.pending) { + // A nested trigger inside the queue re-entered pending; requeue the rest. + this.enqueue(queued[index]!) + continue + } + try { + this.scanAndRelease(queued[index]!) + } catch { + // Why swallowed: one throwing downstream client must not cost the + // rest of the queue its delivery. + } + } + } finally { + // Why finally: an escaping throw must never orphan an attach waiting in + // settleShellOwnershipConfirmation — that wedges createOrAttach forever. + this.resolveIdleWaiters() + } + } + + private enqueue(emission: PtyIngressEmission): void { + this.queue.push(emission) + this.queuedBytes += emission.data.length + if (this.queuedBytes > this.maxQueuedBytes) { + this.finishPending(this.pendingEpisode, false) + } + } + + private resolveIdleWaiters(): void { + const waiters = this.idleWaiters + this.idleWaiters = [] + for (const waiter of waiters) { + waiter() + } + } +} diff --git a/src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts b/src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts new file mode 100644 index 00000000000..76349e202fd --- /dev/null +++ b/src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' + +const CLEAN_EXIT = '\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07' + +function passthrough(data: string, rawStartSeq = 0): PtyIngressEmission { + return { data, rawStartSeq, rawEndSeq: rawStartSeq + data.length, transformed: false } +} + +function createBarrier(confirm: () => Promise, maxPendingMs?: number) { + const released: string[] = [] + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: confirm, + release: (emission) => released.push(emission.data), + isAlive: () => true, + ...(maxPendingMs !== undefined ? { maxPendingMs } : {}) + }) + return Object.assign(barrier, { released }) +} + +describe('clean-exit confirmation retirement', () => { + it('retires a hung clean-exit proof so a later candidate can still prove ownership', async () => { + let calls = 0 + const barrier = createBarrier(() => { + calls += 1 + return calls === 1 ? new Promise(() => {}) : Promise.resolve(true) + }, 20) + + barrier.accept(passthrough(CLEAN_EXIT)) + expect(calls).toBe(1) + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBeUndefined() + + barrier.accept(passthrough(CLEAN_EXIT, CLEAN_EXIT.length)) + await vi.waitFor(() => expect(calls).toBe(2)) + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBe('shell') + }) + + it('contains a synchronously throwing confirm without wedging later candidates', async () => { + let calls = 0 + const barrier = createBarrier(() => { + calls += 1 + if (calls === 1) { + throw new Error('sync boom') + } + return Promise.resolve(true) + }) + + expect(() => barrier.accept(passthrough(CLEAN_EXIT))).not.toThrow() + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBeUndefined() + + barrier.accept(passthrough(CLEAN_EXIT, CLEAN_EXIT.length)) + await vi.waitFor(() => expect(calls).toBe(2)) + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBe('shell') + }) + + it('flushPending releases every byte of an episode storm past the rescan bound', () => { + const barrier = createBarrier(() => new Promise(() => {})) + const episode = '\x1b[?1049hT\x1b]133;D;1\x07x' + const storm = episode.repeat(40) + barrier.accept(passthrough(storm)) + barrier.flushPending() + + // Past the 16-pass rescan bound the remainder is released verbatim; the + // bound trades scanner-model fidelity at teardown, never bytes. + expect(barrier.released.join('')).toBe(storm) + }) +}) diff --git a/src/main/daemon/terminal-snapshot.ts b/src/main/daemon/terminal-snapshot.ts index 6d378a8d107..559d7f2ed08 100644 --- a/src/main/daemon/terminal-snapshot.ts +++ b/src/main/daemon/terminal-snapshot.ts @@ -1,5 +1,6 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalModes } from './terminal-modes' +import type { TerminalOwner } from '../../shared/terminal-owner' export type TerminalSnapshot = { snapshotAnsi: string @@ -19,4 +20,6 @@ export type TerminalSnapshot = { lastTitle?: string /** Optional because persisted snapshots and older v19 daemons lack it. */ outputSequence?: number + /** Ordered shell lifecycle evidence captured at this snapshot boundary. */ + terminalOwner?: TerminalOwner } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 2f1817d14c7..a90e9f5eee5 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,11 +1,13 @@ import type { ConfirmForegroundProcessRequest, + ConfirmShellForegroundRequest, GetForegroundProcessRequest, InspectProcessRequest } from './daemon-foreground-process-protocol' export type { ConfirmForegroundProcessRequest, + ConfirmShellForegroundRequest, GetForegroundProcessRequest, InspectProcessRequest } from './daemon-foreground-process-protocol' @@ -317,6 +319,7 @@ export type DaemonRequest = | GetForegroundProcessRequest | InspectProcessRequest | ConfirmForegroundProcessRequest + | ConfirmShellForegroundRequest | ClearScrollbackRequest | ShutdownRequest | PingRequest diff --git a/src/main/ipc/pty/ipc/snapshot.ts b/src/main/ipc/pty/ipc/snapshot.ts index 607b3bfe781..3724cf7ccfe 100644 --- a/src/main/ipc/pty/ipc/snapshot.ts +++ b/src/main/ipc/pty/ipc/snapshot.ts @@ -43,6 +43,7 @@ export function installPtySnapshotIpcHandlers(deps: { scrollbackAnsi?: string pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { return null diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 7e277c7c3f5..09e6fa8b8c8 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -35,14 +35,14 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise 0) { // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). - ctx.deps.runtime.seedHeadlessTerminal( - ctx.result.id, - ctx.result.snapshot, - snapshotSeedSize, - typeof ctx.result.snapshotKittyKeyboardFlags === 'number' + ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.snapshot, snapshotSeedSize, { + ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' ? { kittyKeyboardFlags: ctx.result.snapshotKittyKeyboardFlags } - : {} - ) + : {}), + ...(ctx.result.snapshotTerminalOwner + ? { terminalOwner: ctx.result.snapshotTerminalOwner } + : {}) + }) } else if ( ctx.result.coldRestore && typeof ctx.result.coldRestore.scrollback === 'string' && diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 65e3cef5030..6d1cd01ead5 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -12,6 +12,7 @@ import { attachPtyFromRuntimeController, clearBufferFromRuntimeController, confirmForegroundProcessFromRuntimeController, + confirmShellForegroundFromRuntimeController, getCwdFromRuntimeController, getForegroundProcessFromRuntimeController, getRendererSerializerGenerationFromRuntimeController, @@ -58,6 +59,7 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), inspectProcess: (ptyId) => inspectProcessFromRuntimeController(ptyId), confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId), + confirmShellForeground: (ptyId) => confirmShellForegroundFromRuntimeController(ptyId), getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 2c64706ceda..5bd3b624a23 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -142,6 +142,14 @@ export async function confirmForegroundProcessFromRuntimeController(ptyId: strin } } +export async function confirmShellForegroundFromRuntimeController(ptyId: string) { + try { + return (await getProviderForPty(ptyId).confirmShellForeground?.(ptyId)) ?? false + } catch { + return false + } +} + export async function getCwdFromRuntimeController(ptyId: string) { try { const cwd = await getProviderForPty(ptyId).getCwd(ptyId) diff --git a/src/main/providers/agent-foreground-process-windows-job-rejection.test.ts b/src/main/providers/agent-foreground-process-windows-job-rejection.test.ts new file mode 100644 index 00000000000..43fe6f2de53 --- /dev/null +++ b/src/main/providers/agent-foreground-process-windows-job-rejection.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { confirmShellForegroundProcess } from './agent-foreground-process' + +const realPlatform = process.platform +afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform }) +}) + +describe('Windows shell confirmation with a rejecting job reader', () => { + it('fails closed instead of rejecting the confirmation', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + // A rejecting reader is missing proof, never a thrown confirmation. + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => { + throw new Error('job handle gone') + } + }) + ).resolves.toBe(false) + }) +}) diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index f747b9e8d05..d1784318df5 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -13,6 +13,7 @@ const getAllProcessesMock = vi.fn() import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot' import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { + confirmShellForegroundProcess, resolveAgentForegroundProcess, resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' @@ -219,6 +220,106 @@ describe('resolveAgentForegroundProcess', () => { await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') }) + it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => { + mockPs(['100 99 Ss+ "/bin/zsh" -l', '101 100 S+ /bin/bash'].join('\n')) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true) + }) + + it('uses spawned-shell identity instead of a lagging foreground child label', async () => { + mockPs(['100 99 Ss+ /bin/zsh -l'].join('\n')) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) + }) + + it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => { + mockPs( + [ + '100 99 Ss /usr/bin/login -pfl developer /bin/zsh', + '101 100 S+ -zsh', + '102 101 S+ (zsh)', + '103 102 S+ (sed)', + '104 102 R+ (git)' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) + }) + + it('rejects a foreground nested shell while the spawned shell remains suspended', async () => { + mockPs( + [ + '100 99 Ss /usr/bin/login -pfl developer /bin/zsh', + '101 100 S -zsh', + '102 101 S+ agent-tui', + '103 102 S+ /bin/zsh -i' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(false) + }) + + it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => { + mockPs( + [ + '100 99 Ss /bin/zsh -l', + '101 100 S+ /usr/local/bin/agent-tui', + '102 101 S+ /bin/bash -i' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('rejects shell ownership while a stopped TUI remains resumable', async () => { + mockPs(['100 99 Ss+ /bin/zsh -l', '101 100 T /usr/local/bin/agent-tui'].join('\n')) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('refuses WSL shells: wsl.exe is not a provable shell identity', async () => { + // Why pinned: the WSL job object holds only wsl.exe, so a looser + // isShellProcess would confirm ownership regardless of distro-side state. + await expect(confirmShellForegroundProcess(100, 'wsl.exe')).resolves.toBe(false) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('fails open when fresh shell ownership inspection is unavailable', async () => { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) + } + ) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('confirms a Windows shell from fresh root-only ConPTY membership', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const readWindowsPtyJobProcessIds = vi.fn(async () => new Set([100])) + + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { readWindowsPtyJobProcessIds }) + ).resolves.toBe(true) + expect(getAllProcessesMock).not.toHaveBeenCalled() + }) + + it('rejects Windows shell ownership with a child or unavailable membership', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => new Set([100, 101]) + }) + ).resolves.toBe(false) + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => null + }) + ).resolves.toBe(false) + }) + it('does not report Claude print-mode hook descendants as foreground agents', async () => { mockPs( [ diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 5242bb2b911..ef5d6cb58f8 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -10,6 +10,7 @@ import { shouldInspectWindowsAgentForeground, type AgentForegroundResolutionOptions } from './windows-agent-foreground-process' +import { isShellProcess } from '../../shared/shell-process-detection' export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process' @@ -26,6 +27,13 @@ export type AgentForegroundProcessResolution = { anchorPidForeign?: boolean } +type ShellForegroundConfirmationOptions = { + readWindowsPtyJobProcessIds?: () => + | ReadonlySet + | null + | Promise | null> +} + function collectDescendants( rows: Row[], rootPid: number @@ -49,6 +57,61 @@ function collectDescendants( return descendants } +function commandExecutable(command: string): string { + const trimmed = command.trim().replace(/^[-]/, '') + if (trimmed.startsWith('"') || trimmed.startsWith("'")) { + const closingQuote = trimmed.indexOf(trimmed[0], 1) + return closingQuote === -1 ? trimmed.slice(1) : trimmed.slice(1, closingQuote) + } + return trimmed.split(/\s+/, 1)[0] ?? '' +} + +function executableBasename(command: string): string { + return commandExecutable(command).split(/[\\/]/).pop()?.toLowerCase() ?? '' +} + +export async function confirmShellForegroundProcess( + shellPid: number | null | undefined, + spawnedShellProcess: string | null | undefined, + options: ShellForegroundConfirmationOptions = {} +): Promise { + if (!shellPid || !spawnedShellProcess || !isShellProcess(spawnedShellProcess)) { + return false + } + if (process.platform === 'win32') { + try { + const processIds = await options.readWindowsPtyJobProcessIds?.() + return processIds?.size === 1 && processIds.has(shellPid) + } catch { + // Unavailable job inspection is missing proof, never a thrown confirmation. + return false + } + } + try { + const rows = await getFreshProcessTableSnapshot() + if (!rows.some((row) => row.pid === shellPid)) { + return false + } + const root = rows.find((row) => row.pid === shellPid)! + const tree = [{ ...root, depth: 0 }, ...collectDescendants(rows, shellPid)] + const spawnedShellBasename = executableBasename(spawnedShellProcess) + const foregroundShell = tree + .filter( + (row) => + executableBasename(row.command) === spawnedShellBasename && + isShellProcess(commandExecutable(row.command)) + ) + .sort((left, right) => left.depth - right.depth)[0] + if (tree.some((row) => row.depth > 0 && row.stat.includes('T'))) { + return false + } + const confirmed = foregroundShell?.stat.includes('+') === true + return confirmed + } catch { + return false + } +} + function candidateScore(row: ProcessTableRow & { depth: number }): number { // Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer // them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index d8ddc7cd902..223168b65c0 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -59,7 +59,10 @@ import { resolveWindowsGitBashShellPath } from '../git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' +import { + confirmShellForegroundProcess, + resolveAgentForegroundProcessWithAvailability +} from './agent-foreground-process' import { resolveStableForegroundProcess } from './stable-foreground-process' import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' @@ -1563,6 +1566,21 @@ export class LocalPtyProvider implements IPtyProvider { } } + async confirmShellForeground(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return false + } + const confirmed = await confirmShellForegroundProcess( + proc.pid, + ptyShellName.get(id), + process.platform === 'win32' + ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(proc) } + : {} + ) + return ptyProcesses.get(id) === proc && confirmed + } + async serialize(_ids: string[]): Promise { return '{}' } diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 333d6fcc62c..d77d6aec1a5 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -11,6 +11,7 @@ import type { } from '../../shared/agent-session-host-authority' import type { PtyProcessInfo } from './pty-process-info' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { TerminalOwner } from '../../shared/terminal-owner' export type { PtyBackgroundStreamEvent, @@ -37,6 +38,8 @@ export type PtyProviderBufferSnapshot = { * boundary. Absent means the source could not prove them; readers must not * rewrite that silence into a known `0`. */ kittyKeyboardFlags?: number + /** Ordered ownership evidence proven at this snapshot's `seq`. */ + terminalOwner?: TerminalOwner } export type PtySpawnOptions = { @@ -209,6 +212,8 @@ export type IPtyProvider = { getForegroundProcess(id: string): Promise /** Strong process evidence captured after the caller's command boundary. */ confirmForegroundProcess?: (id: string) => Promise + /** Fresh execution-host proof that the spawned shell owns the PTY foreground. */ + confirmShellForeground?: (id: string) => Promise serialize(ids: string[]): Promise revive(state: string): Promise // Why: deadlineMs bounds the underlying RPC exactly like shutdown's deadlineMs. diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 76bb4179069..01cf9193f7e 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -3,6 +3,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' +import type { TerminalOwner } from '../../shared/terminal-owner' export type PtySpawnResult = { agentSessionEnsure?: AgentSessionClaimedSpawnResult @@ -52,6 +53,8 @@ export type PtySpawnResult = { /** Renderer-domain sequence main reconciled for the attach boundary those * flags describe. Set by main, not the provider. */ snapshotSeq?: number + /** Ordered ownership evidence proven by the provider snapshot. */ + snapshotTerminalOwner?: TerminalOwner /** True when the spawn reattached to an existing daemon session. */ isReattach?: boolean /** Last OSC title tracked by the daemon session the snapshot came from. diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 0fc8702750d..08a73109e5c 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -25,6 +25,13 @@ describe('SshPtyProvider', () => { expect(provider.canProvideAuthoritativeBufferSnapshot(scopedPty1)).toBe(false) }) + it('fails closed without foreground-shell proof on direct SSH', () => { + expect( + (provider as { confirmShellForeground?: unknown }).confirmShellForeground + ).toBeUndefined() + expect(mux.request).not.toHaveBeenCalled() + }) + it('keeps a shared claim probe alive when one waiter disconnects', async () => { let finishProbe!: (result: { agentSessionClaimVersion: number }) => void mux.request.mockReturnValueOnce( diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index f61272de8c3..6667e3fd608 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -1,6 +1,4 @@ -/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync - * with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which - * builds the UNC twin of this path. */ +/** Guest-relative layout of Orca's retired WSL CODEX_HOME, retained for migration reads. */ export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [ '.local', 'share', diff --git a/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts b/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts new file mode 100644 index 00000000000..7fe33b66ee1 --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type * as GitUsernameModule from '../git/git-username' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../hooks', () => ({ + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) +vi.mock('../worktree-runner-script', () => ({ createSetupRunnerScript: vi.fn() })) + +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) + +vi.mock('../ipc/registered-worktree-roots-cache', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) + +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null) + } +}) + +vi.mock('../git/git-username', async () => { + const actual = await vi.importActual('../git/git-username') + return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), + addRetiredWorktreeName: () => {}, + mergeRetiredWorktreeNames: () => false, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + mobileAutoRestoreFitMs: null + }), + updateSettings: () => {} +} + +type SessionTabsPrivate = { + mobileSessionTabsByWorktree: Map + touchMobileSessionSnapshotsForPty: (ptyId: string) => void + notifyMobileSessionTabsChanged: (worktreeId?: string) => void +} + +function seedPtyBackedSnapshot(runtime: OrcaRuntimeService, ptyId: string): void { + const priv = runtime as unknown as SessionTabsPrivate + priv.mobileSessionTabsByWorktree.set('worktree-a', { + worktree: 'worktree-a', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1', + title: 'agent', + parentTabId: 'group-1', + leafId: 'leaf-1', + ptyId, + isActive: true + } + ] + }) +} + +describe('session.tabs title/status churn coalescing', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('collapses a rapid title-flip burst into a single emit (repro)', () => { + const runtime = new OrcaRuntimeService(store) + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // A spinner-in-title agent flips the title ~20 times within a second. + const FLIPS = 20 + for (let i = 0; i < FLIPS; i++) { + priv.touchMobileSessionSnapshotsForPty('pty-1') + vi.advanceTimersByTime(10) + } + // Nothing has fired yet — all flips landed inside the trailing window. + expect(emits).toHaveLength(0) + + vi.advanceTimersByTime(50) + + // Pre-fix this fanned out one emit per flip; now it is a single emit. + expect(emits).toHaveLength(1) + // The emitted version is the freshest (monotonic, never a stale one). + const stored = priv.mobileSessionTabsByWorktree.get('worktree-a') + expect(emits[0]).toBe(stored?.snapshotVersion) + expect(emits[0]).toBe(1 + FLIPS) + + unsubscribe() + }) + + it('lets a structural change emit immediately and supersede the coalesced notify', () => { + const runtime = new OrcaRuntimeService(store) + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // Title churn schedules a coalesced notify... + priv.touchMobileSessionSnapshotsForPty('pty-1') + // ...then a structural change (e.g. tab activated) demands a prompt emit. + priv.notifyMobileSessionTabsChanged('worktree-a') + expect(emits).toHaveLength(1) + + // The immediate emit cancelled the pending coalesced notify — no duplicate trailing emit. + vi.advanceTimersByTime(50) + expect(emits).toHaveLength(1) + + unsubscribe() + }) + + it('flushes a pending notify when the last subscriber closes', () => { + const runtime = new OrcaRuntimeService(store) + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + priv.touchMobileSessionSnapshotsForPty('pty-1') + expect(emits).toHaveLength(0) + + // Closing the subscription flushes the pending window so the final state reaches the listener. + unsubscribe() + expect(emits).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index 292cc13a816..4a7195e3963 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -3,7 +3,6 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type * as GitUsernameModule from '../git/git-username' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { OrcaRuntimeService } from './orca-runtime' vi.mock('../git/worktree', () => ({ @@ -211,6 +210,7 @@ describe('mobile subscribe integration', () => { } outputSequence: number writeChain: Promise + ownership: { settle: () => Promise; owner: undefined } } const runtimePrivate = runtime as unknown as { headlessTerminals: Map @@ -221,7 +221,8 @@ describe('mobile subscribe integration', () => { getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 }) }, outputSequence: 17, - writeChain: Promise.resolve() + writeChain: Promise.resolve(), + ownership: { settle: async () => {}, owner: undefined } }) await expect(runtime.serializeMainTerminalBuffer('pty-empty')).resolves.toEqual({ @@ -979,107 +980,4 @@ describe('mobile subscribe integration', () => { expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) }) }) - - describe('session.tabs title/status churn coalescing', () => { - type SessionTabsPrivate = { - mobileSessionTabsByWorktree: Map - touchMobileSessionSnapshotsForPty: (ptyId: string) => void - notifyMobileSessionTabsChanged: (worktreeId?: string) => void - } - - function seedPtyBackedSnapshot(runtime: OrcaRuntimeService, ptyId: string): void { - const priv = runtime as unknown as SessionTabsPrivate - priv.mobileSessionTabsByWorktree.set('worktree-a', { - worktree: 'worktree-a', - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: 'group-1', - activeTabId: 'tab-1', - activeTabType: 'terminal', - tabs: [ - { - type: 'terminal', - id: 'tab-1', - title: 'agent', - parentTabId: 'group-1', - leafId: 'leaf-1', - ptyId, - isActive: true - } - ] - }) - } - - it('collapses a rapid title-flip burst into a single emit (repro)', () => { - const { runtime } = createRuntime() - seedPtyBackedSnapshot(runtime, 'pty-1') - const priv = runtime as unknown as SessionTabsPrivate - - const emits: number[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emits.push(snapshot.snapshotVersion) - }) - - // A spinner-in-title agent flips the title ~20 times within a second. - const FLIPS = 20 - for (let i = 0; i < FLIPS; i++) { - priv.touchMobileSessionSnapshotsForPty('pty-1') - vi.advanceTimersByTime(10) - } - // Nothing has fired yet — all flips landed inside the trailing window. - expect(emits).toHaveLength(0) - - vi.advanceTimersByTime(50) - - // Pre-fix this fanned out one emit per flip; now it is a single emit. - expect(emits).toHaveLength(1) - // The emitted version is the freshest (monotonic, never a stale one). - const stored = priv.mobileSessionTabsByWorktree.get('worktree-a') - expect(emits[0]).toBe(stored?.snapshotVersion) - expect(emits[0]).toBe(1 + FLIPS) - - unsubscribe() - }) - - it('lets a structural change emit immediately and supersede the coalesced notify', () => { - const { runtime } = createRuntime() - seedPtyBackedSnapshot(runtime, 'pty-1') - const priv = runtime as unknown as SessionTabsPrivate - - const emits: number[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emits.push(snapshot.snapshotVersion) - }) - - // Title churn schedules a coalesced notify... - priv.touchMobileSessionSnapshotsForPty('pty-1') - // ...then a structural change (e.g. tab activated) demands a prompt emit. - priv.notifyMobileSessionTabsChanged('worktree-a') - expect(emits).toHaveLength(1) - - // The immediate emit cancelled the pending coalesced notify — no duplicate trailing emit. - vi.advanceTimersByTime(50) - expect(emits).toHaveLength(1) - - unsubscribe() - }) - - it('flushes a pending notify when the last subscriber closes', () => { - const { runtime } = createRuntime() - seedPtyBackedSnapshot(runtime, 'pty-1') - const priv = runtime as unknown as SessionTabsPrivate - - const emits: number[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emits.push(snapshot.snapshotVersion) - }) - - priv.touchMobileSessionSnapshotsForPty('pty-1') - expect(emits).toHaveLength(0) - - // Closing the subscription flushes the pending window so the final state reaches the listener. - unsubscribe() - expect(emits).toHaveLength(1) - }) - }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7afac2fde09..2852a8c66cd 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11817,6 +11817,50 @@ describe('OrcaRuntimeService', () => { }) }) + it('binds shell ownership evidence to the headless snapshot sequence', async () => { + const runtime = createRuntime() + let resolveConfirmation: ((confirmed: boolean) => void) | undefined + const confirmShellForeground = vi.fn( + () => new Promise((resolve) => void (resolveConfirmation = resolve)) + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + confirmShellForeground + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', '\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker', 100) + const shellSnapshotPromise = runtime.serializeHiddenOutputRecoveryBuffer('pty-1') + await vi.waitFor(() => expect(confirmShellForeground).toHaveBeenCalledTimes(1)) + let snapshotSettled = false + void shellSnapshotPromise.then(() => { + snapshotSettled = true + }) + await Promise.resolve() + expect(snapshotSettled).toBe(false) + + resolveConfirmation?.(true) + const shellSnapshot = await shellSnapshotPromise + // Why alternateScreen stays true here: the mirror never rewrites its own + // model — without a daemon barrier injecting the reset in-stream (direct + // provider path), the snapshot publishes the poisoned mode alongside the + // proof and the renderer's dead-TUI branch grounds the pane. + expect(shellSnapshot).toMatchObject({ + alternateScreen: true, + terminalOwner: 'shell', + seq: '\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker'.length + }) + expect(confirmShellForeground).toHaveBeenCalledTimes(1) + + runtime.onPtyData('pty-1', '\x1b]133;C\x07\x1b[?1049hLIVE-TUI', 101) + const liveSnapshot = await runtime.serializeHiddenOutputRecoveryBuffer('pty-1') + + expect(liveSnapshot?.alternateScreen).toBe(true) + expect(liveSnapshot?.terminalOwner).toBeUndefined() + }) + it('keeps an empty headless snapshot authoritative for hidden-output recovery', async () => { const serializeBuffer = vi.fn().mockResolvedValue({ data: 'stale renderer content\r\n', @@ -11843,6 +11887,7 @@ describe('OrcaRuntimeService', () => { } outputSequence: number writeChain: Promise + ownership: { settle: () => Promise; owner: undefined } } const runtimePrivate = runtime as unknown as { headlessTerminals: Map @@ -11853,7 +11898,8 @@ describe('OrcaRuntimeService', () => { getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 }) }, outputSequence: 17, - writeChain: Promise.resolve() + writeChain: Promise.resolve(), + ownership: { settle: async () => {}, owner: undefined } }) await expect(runtime.serializeHiddenOutputRecoveryBuffer('pty-empty')).resolves.toEqual({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b9d315243f2..a448c931a83 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1248,6 +1248,7 @@ import { } from '../ipc/watcher-removal-drain' import { withWorktreeSpan } from '../observability/instrumentation' import { HeadlessEmulator } from '../daemon/headless-emulator' +import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' import { isNativeWindowsConptyPty, registerConptyDa1OverrideInstaller, @@ -1919,6 +1920,7 @@ type RuntimeHeadlessTerminal = { // the seq actually painted into this emulator, not the latest PTY seq. outputSequence: number writeChain: Promise + ownership: PtyShellOwnershipMirror } export type RuntimePtyDataAdmission = Readonly<{ @@ -1972,6 +1974,7 @@ type RuntimeTerminalBufferSnapshot = { /** Effective kitty flags proven at this snapshot's own `seq`. Absent means * the winning source could not prove them. */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } type HeadlessSeedMetadata = { @@ -1983,6 +1986,7 @@ type HeadlessSeedMetadata = { * emulator so hidden `CSI ? u` answers the real flags instead of ?0u * (terminal-query-authority.md §kitty). */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } type RuntimePtyController = { @@ -2084,6 +2088,7 @@ type RuntimePtyController = { ptyId: string ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> confirmForegroundProcess?(ptyId: string): Promise + confirmShellForeground?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean @@ -14389,6 +14394,7 @@ export class OrcaRuntimeService { oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean scrollbackAnsi?: string + terminalOwner?: 'shell' } | null> { return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) } @@ -14409,6 +14415,7 @@ export class OrcaRuntimeService { alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string + terminalOwner?: 'shell' } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, @@ -14509,6 +14516,12 @@ export class OrcaRuntimeService { if (metadata.oscLinks !== undefined) { state.emulator.setRestoredOscLinks(metadata.oscLinks) } + // Why derived from the emulator: the seed bytes bypass ownership.scan, + // so the scanner must inherit the restored alternate-screen state or a + // pane seeded mid-TUI never arms its recovery trigger. + state.ownership.seedOwner(metadata.terminalOwner, { + alternateScreen: state.emulator.isAlternateScreen + }) this.providerSnapshotPreferredPtys.delete(ptyId) }) .catch(() => { @@ -14616,6 +14629,9 @@ export class OrcaRuntimeService { // (they feed main's tracker only), so its serializer lastTitle can be // stale here. Prefer main's tracked title; the renderer's is only the // seed when main has observed none (fresh relaunch, cold tracker). + state.ownership.seedOwner(undefined, { + alternateScreen: state.emulator.isAlternateScreen + }) const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle if (seedTitle) { state.emulator.setLastTitle(seedTitle) @@ -14691,6 +14707,9 @@ export class OrcaRuntimeService { const completion = state.writeChain.then(async () => { // Why: the ingestion-time ownership decision is closed over this // chain link; async scheduling cannot retroactively change it. + // Why inside the chain: the ownership mirror must observe live bytes in + // the same total order as seeds (seedOwner also runs on this chain). + state.ownership.scan(data) await state.emulator.write(data, { forwardQueryReplies }) state.outputSequence = outputSequence }) @@ -14750,7 +14769,28 @@ export class OrcaRuntimeService { if (viewAttributes) { emulator.applyPushedViewAttributes(viewAttributes) } - state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } + const constructed: RuntimeHeadlessTerminal = { + emulator, + outputSequence: 0, + writeChain: Promise.resolve(), + ownership: new PtyShellOwnershipMirror(async () => { + const controller = this.ptyController + const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId) + if ( + !controller?.confirmShellForeground || + this.headlessTerminals.get(ptyId) !== constructed + ) { + return false + } + const confirmed = await controller.confirmShellForeground(ptyId) + return ( + confirmed && + this.headlessTerminals.get(ptyId) === constructed && + this.getPtyLifecycleGeneration(ptyId) === lifecycleGeneration + ) + }) + } + state = constructed return state } @@ -14802,6 +14842,9 @@ export class OrcaRuntimeService { if (snapshot.oscLinks !== undefined) { state.emulator.setRestoredOscLinks(snapshot.oscLinks) } + state.ownership.seedOwner(snapshot.terminalOwner, { + alternateScreen: state.emulator.isAlternateScreen + }) state.outputSequence = snapshot.seq }) .catch(() => { @@ -14860,6 +14903,7 @@ export class OrcaRuntimeService { alternateScreen?: boolean pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> { if (this.providerSnapshotPreferredPtys.has(ptyId)) { // Why: pre-attach stream bytes only form a suffix of restored state. A @@ -15340,6 +15384,7 @@ export class OrcaRuntimeService { alternateScreen?: boolean scrollbackAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' // Why: dangling mid-escape tail the restorer must write LAST, after any // reset, so the next live chunk completes it instead of rendering it // literally (Bug E / #7329). @@ -15350,11 +15395,12 @@ export class OrcaRuntimeService { return null } await state.writeChain + await state.ownership.settle() // Why: normal history is separated from an active alternate frame, so the // caller's scrollback policy can be honored without painting it into alt. - const isAlternateScreen = state.emulator.isAlternateScreen const scrollbackRows = opts.scrollbackRows ?? 0 const snapshot = state.emulator.getSnapshot({ scrollbackRows }) + const terminalOwner = state.ownership.owner const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi return data.length > 0 || opts.includeEmpty === true ? this.preferTrackedLastTitle(ptyId, { @@ -15377,10 +15423,11 @@ export class OrcaRuntimeService { ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), + ...(terminalOwner ? { terminalOwner } : {}), // Why: lets the renderer skip the destructive scrollback clear when // restoring an alt-screen snapshot — clearing wipes xterm's own // history that the TUI relies on for scroll-up after a tab return. - alternateScreen: isAlternateScreen, + alternateScreen: snapshot.modes?.alternateScreen ?? state.emulator.isAlternateScreen, // Why NOT folded into data: the renderer writes its post-replay // reset after data, and any ESC after a dangling partial aborts it. // The restorer writes this last (Bug E fix). @@ -15400,6 +15447,7 @@ export class OrcaRuntimeService { // sever the reply sink now so they cannot write to a respawned PTY that // reused this id (belt to the sink's state-identity check). state.emulator.disableQueryReplyForwarding() + state.ownership.dispose() state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) } diff --git a/src/main/runtime/pty-shell-ownership-mirror.test.ts b/src/main/runtime/pty-shell-ownership-mirror.test.ts new file mode 100644 index 00000000000..20468a194cd --- /dev/null +++ b/src/main/runtime/pty-shell-ownership-mirror.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' + +describe('PtyShellOwnershipMirror', () => { + it('arms the recovery trigger from a seeded alternate-screen state', async () => { + const confirm = vi.fn(async () => true) + const mirror = new PtyShellOwnershipMirror(confirm) + + // Restored bytes bypass scan(); the seed must carry the alt state or a + // pane reattached mid-TUI never proves ownership after the TUI dies. + mirror.seedOwner(undefined, { alternateScreen: true }) + mirror.scan('\x1b]133;D;137\x07') + + await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)) + await mirror.settle() + expect(mirror.owner).toBe('shell') + }) + + it('does not arm from a seed on the normal buffer', async () => { + const confirm = vi.fn(async () => true) + const mirror = new PtyShellOwnershipMirror(confirm) + + mirror.seedOwner(undefined, { alternateScreen: false }) + mirror.scan('\x1b]133;D;0\x07') + + expect(confirm).not.toHaveBeenCalled() + expect(mirror.owner).toBeUndefined() + }) + + it('revokes a seeded owner on later TUI bytes', () => { + const mirror = new PtyShellOwnershipMirror(async () => true) + mirror.seedOwner('shell', { alternateScreen: false }) + expect(mirror.owner).toBe('shell') + + mirror.scan('\x1b[?1003h') + expect(mirror.owner).toBeUndefined() + }) + + it('bounds settle() when a proof hangs', async () => { + const mirror = new PtyShellOwnershipMirror(() => new Promise(() => {})) + mirror.seedOwner(undefined, { alternateScreen: true }) + mirror.scan('\x1b]133;D;137\x07') + + const start = Date.now() + await mirror.settle() + expect(Date.now() - start).toBeLessThan(2000) + expect(mirror.owner).toBeUndefined() + }) + + it('retires a hung confirmation so a later candidate can still prove ownership', async () => { + let calls = 0 + const mirror = new PtyShellOwnershipMirror(() => { + calls += 1 + return calls === 1 ? new Promise(() => {}) : Promise.resolve(true) + }) + mirror.seedOwner(undefined, { alternateScreen: true }) + mirror.scan('\x1b]133;D;137\x07') + // The settle deadline outlives the attempt deadline, so this waits out the + // retirement of the hung attempt. + await mirror.settle() + expect(mirror.owner).toBeUndefined() + + mirror.scan('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(calls).toBe(2)) + await mirror.settle() + expect(mirror.owner).toBe('shell') + }) + + it('contains a synchronously throwing confirm callback', async () => { + let calls = 0 + const mirror = new PtyShellOwnershipMirror(() => { + calls += 1 + if (calls === 1) { + throw new Error('sync boom') + } + return Promise.resolve(true) + }) + mirror.seedOwner(undefined, { alternateScreen: true }) + expect(() => mirror.scan('\x1b]133;D;137\x07')).not.toThrow() + await mirror.settle() + expect(mirror.owner).toBeUndefined() + + mirror.scan('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(calls).toBe(2)) + await mirror.settle() + expect(mirror.owner).toBe('shell') + }) +}) diff --git a/src/main/runtime/pty-shell-ownership-mirror.ts b/src/main/runtime/pty-shell-ownership-mirror.ts new file mode 100644 index 00000000000..5c54465be75 --- /dev/null +++ b/src/main/runtime/pty-shell-ownership-mirror.ts @@ -0,0 +1,149 @@ +import { TerminalShellLifecycleScanner } from '../daemon/terminal-shell-lifecycle-scanner' +import type { TerminalOwner } from '../../shared/terminal-owner' + +// Why bounded: a serialize awaiting settle() must not inherit a slow daemon RPC +// or ps fork; a proof that lands later is still generation-guarded. +const SETTLE_DEADLINE_MS = 750 + +/** + * Mirror-side shell ownership for the runtime's per-PTY headless emulator. + * Never pauses or injects: for daemon-backed PTYs the host barrier already + * ordered the stream (any recovery reset arrives as ordinary bytes), so a + * trigger here is only a proof candidate answered by the host's settled + * verdict; for direct local PTYs the answer is a fresh process inspection. + * Fed the same bytes the emulator parses, in the same order. + */ +export class PtyShellOwnershipMirror { + private readonly scanner = new TerminalShellLifecycleScanner() + private confirmInFlight = false + private confirmAttempt = 0 + private latestCandidateGeneration: number | undefined + private settleWaiters: (() => void)[] = [] + private disposed = false + + constructor(private readonly confirm: () => Promise) {} + + scan(data: string): void { + if (this.disposed) { + return + } + let rest = data + while (rest.length > 0) { + const events = this.scanner.scan(rest) + if (events.cleanExitCandidate) { + this.startConfirmation(events.cleanExitCandidate.generation) + } + if (events.uncleanDeathTriggerEnd === undefined) { + return + } + // The stream owner already resolved this boundary; ask it, keep scanning. + this.startConfirmation(this.scanner.generation) + rest = rest.slice(events.uncleanDeathTriggerEnd) + } + } + + get owner(): TerminalOwner | undefined { + return this.scanner.owner + } + + seedOwner(owner: TerminalOwner | undefined, opts: { alternateScreen?: boolean } = {}): void { + this.scanner.seedOwner(owner, opts) + } + + /** Waits out an in-flight proof for the current generation; a generation + * advance past the entry point ends the wait (that proof is stale), and the + * wait is bounded — serialize latency must never inherit a slow RPC or + * process inspection; an unsettled proof just reads as no owner. */ + async settle(): Promise { + const target = this.scanner.generation + let deadlineHit = false + const deadline = setTimeout(() => { + deadlineHit = true + this.resolveSettleWaiters() + }, SETTLE_DEADLINE_MS) + deadline.unref?.() + try { + while ( + this.confirmInFlight && + this.scanner.generation <= target && + !this.disposed && + !deadlineHit + ) { + await new Promise((resolve) => this.settleWaiters.push(resolve)) + } + } finally { + clearTimeout(deadline) + } + } + + dispose(): void { + this.disposed = true + this.resolveSettleWaiters() + } + + private startConfirmation(generation: number): void { + this.latestCandidateGeneration = generation + if (this.confirmInFlight || this.disposed) { + return + } + this.confirmInFlight = true + const requested = generation + const attempt = ++this.confirmAttempt + // Why the deadline: a confirm that never settles (hung RPC) must not wedge + // ownership confirmation for the rest of the PTY's life; retire the attempt + // so a later candidate can start a fresh one. + const deadline = setTimeout( + () => this.retireConfirmAttempt(attempt, requested), + SETTLE_DEADLINE_MS + ) + deadline.unref?.() + // Why the guard: the callback is injected; a synchronous throw must not + // strand confirmInFlight. + let proof: Promise + try { + proof = this.confirm() + } catch { + proof = Promise.resolve(false) + } + void proof + .then((confirmed) => { + // A retired attempt's late verdict is inert: an unsettled proof already + // read as no owner, and flipping it afterwards would race fresh bytes. + if (attempt === this.confirmAttempt && confirmed && !this.disposed) { + this.scanner.trySetOwner(requested) + } + }) + .catch(() => {}) + .finally(() => { + clearTimeout(deadline) + this.retireConfirmAttempt(attempt, requested) + }) + } + + private retireConfirmAttempt(attempt: number, requested: number): void { + if (attempt !== this.confirmAttempt) { + return + } + this.confirmAttempt += 1 + this.confirmInFlight = false + const latest = this.latestCandidateGeneration + if ( + latest !== undefined && + latest !== requested && + latest === this.scanner.generation && + !this.disposed + ) { + // One superseding candidate can still be current; stale ones are not retried. + this.startConfirmation(latest) + } + this.resolveSettleWaiters() + } + + private resolveSettleWaiters(): void { + const waiters = this.settleWaiters + this.settleWaiters = [] + for (const waiter of waiters) { + waiter() + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index fcae3d02f6f..72c8939cb6c 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -162,6 +162,8 @@ export async function publishLegacyBinaryInitialSnapshot( truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, oscLinks: serialized?.oscLinks, + alternateScreen: serialized?.alternateScreen, + terminalOwner: serialized?.terminalOwner, data: serialized?.data ?? '' }) console.log('[mobile-terminal-stream] snapshot', { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts index a469b2d7a00..ac9e69dd0fc 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts @@ -76,6 +76,8 @@ export function installMultiplexFlowControl( seq: serialized.seq, source: serialized.source, kittyKeyboardFlags: serialized.kittyKeyboardFlags, + alternateScreen: serialized.alternateScreen, + terminalOwner: serialized.terminalOwner, truncatedByByteBudget: serialized.truncatedByByteBudget, data: serialized.data } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index 724e14f2cc5..6f1c2f8fc47 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -95,6 +95,8 @@ export async function publishMultiplexInitialSnapshot( truncatedByByteBudget: serialized?.truncatedByByteBudget, source: serialized?.source, kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + alternateScreen: serialized?.alternateScreen, + terminalOwner: serialized?.terminalOwner, oscLinks: serialized?.oscLinks, pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts index 7a4ae50e701..1020b2542d4 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -235,6 +235,8 @@ export function installMultiplexSlotFrames( cwd: serialized?.cwd, source: serialized?.source, kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + alternateScreen: serialized?.alternateScreen, + terminalOwner: serialized?.terminalOwner, oscLinks: serialized?.oscLinks, pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts index 33b09a0ead9..39f4465b8e3 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts @@ -79,6 +79,17 @@ export function sendSnapshotFrames( ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags: options.kittyKeyboardFlags } : {}), + ...(typeof options.seq === 'number' && options.terminalOwner + ? { terminalOwner: options.terminalOwner } + : {}), + // The terminalOwner conjunct is load-bearing, not redundant: no consumer + // re-checks it, and an un-gated alternateScreen would flip the renderer's + // mouse-reset selection on every alt-screen reattach of a live TUI. + ...(typeof options.seq === 'number' && + options.terminalOwner && + options.alternateScreen !== undefined + ? { alternateScreen: options.alternateScreen } + : {}), truncated: options.truncated === true, truncatedByByteBudget: options.truncatedByByteBudget === true }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts index 484a9f0f933..121cf9a7adb 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -27,6 +27,8 @@ export type SnapshotFrameOptions = { pendingEscapeTailAnsi?: string /** Effective kitty flags proven at this frame's own `seq`. */ kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } export type SerializedSnapshot = { @@ -42,6 +44,8 @@ export type SerializedSnapshot = { truncatedByByteBudget: boolean pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } | null export type TerminalViewportClient = { diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 88125c840b3..4b9afed46c6 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -47,8 +47,11 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { data: 'user@host:~$ ', cols: 80, rows: 24, + seq: 7, // The dangling partial the emulator could not serialize. - pendingEscapeTailAnsi: '\x1b[3' + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' }), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), @@ -119,7 +122,9 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { .map((frame) => decodeTerminalStreamFrame(frame)) .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)! expect(decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({ - pendingEscapeTailAnsi: '\x1b[3' + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' }) runtime.cleanupSubscription('terminal-multiplex:conn-1') diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index f7381c5665c..b6c53668f2d 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -595,6 +595,7 @@ describe('terminal subscribe buffering', () => { endCol: number uri: string }[] + terminalOwner?: 'shell' }) => void)[] = [] const serializeTerminalBuffer = vi .fn() @@ -611,6 +612,7 @@ describe('terminal subscribe buffering', () => { endCol: number uri: string }[] + terminalOwner?: 'shell' }>((resolve) => { restreamResolves.push(resolve) }) @@ -682,7 +684,8 @@ describe('terminal subscribe buffering', () => { data: 'newer', cols: 100, rows: 24, - oscLinks: newerOscLinks + oscLinks: newerOscLinks, + terminalOwner: 'shell' }) await vi.waitFor(() => expect( @@ -711,6 +714,9 @@ describe('terminal subscribe buffering', () => { kind: 'resized', oscLinks: newerOscLinks }) + expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).not.toHaveProperty( + 'terminalOwner' + ) runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index befefb5a2c8..e226814ba84 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -17,7 +17,6 @@ main/agent-hooks/wsl-hook-relay-launch.ts main/browser/wsl-browser-network-relay-launch.ts main/claude-accounts/claude-command-process.ts -main/codex-accounts/runtime-home-service.ts main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index d69d906047d..fc558b60d74 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -57,6 +57,7 @@ export type PtyApi = { snapshotFrameAnsi?: string snapshotFrameRestoreAnsi?: string snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' snapshotSeq?: number isReattach?: boolean isAlternateScreen?: boolean @@ -143,6 +144,7 @@ export type PtyApi = { /** Effective kitty flags the snapshot owner proved at `seq`. Absent means * unknown; consumers must not turn that into a known `0`. */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ pendingPtyCount: number diff --git a/src/preload/index.ts b/src/preload/index.ts index 9e8af7df404..277ac733484 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1060,6 +1060,7 @@ const api = { snapshotFrameAnsi?: string snapshotFrameRestoreAnsi?: string snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' snapshotSeq?: number isReattach?: boolean isAlternateScreen?: boolean @@ -1190,6 +1191,7 @@ const api = { scrollbackAnsi?: string pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), getRendererDeliveryDebugSnapshot: (): Promise<{ diff --git a/src/renderer/src/components/editor/editor-header-file-rename.ts b/src/renderer/src/components/editor/editor-header-file-rename.ts index 16f5e557faa..e8a787dd2b9 100644 --- a/src/renderer/src/components/editor/editor-header-file-rename.ts +++ b/src/renderer/src/components/editor/editor-header-file-rename.ts @@ -42,7 +42,6 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil const commitRename = (): void => { if (renameCancelledRef.current) { - renameCancelledRef.current = false setIsRenaming(false) return } @@ -52,6 +51,9 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil return } const newName = input.value.trim() + // onBlur follows Enter when the input unmounts; consume that trailing event + // so one user action cannot start a second rename against the old path. + renameCancelledRef.current = true setIsRenaming(false) if (!newName || newName === currentFileName) { return diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 4edf74a7cba..6eb614550c9 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -454,6 +454,30 @@ describe('EditorFileTab rename menu', () => { }) }) + it('does not re-commit when unmounting the rename input emits multiple blur events', async () => { + const file = baseFile() + const firstRender = expandNode((await renderEditorFileTab(file)).element) + const renameItem = findMenuItemByText(firstRender, 'Rename') + + ;(renameItem.props.onSelect as () => void)() + + const secondRender = expandNode((await renderEditorFileTab(file)).element) + const input = findElementsByType(secondRender, 'input')[0] + const setInputRef = input.props.ref as (input: HTMLInputElement | null) => void + setInputRef({ + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + value: 'renamed.md' + } as unknown as HTMLInputElement) + + pressInputKey(input, 'Enter') + ;(input.props.onBlur as () => void)() + ;(input.props.onBlur as () => void)() + + expect(renameFileOnDiskMock).toHaveBeenCalledTimes(1) + }) + it('disables Rename for diff tabs that do not map to one writable file', async () => { const file = baseFile({ mode: 'diff', diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index aca2014bb91..028c0b745b6 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -132,7 +132,6 @@ export default function EditorFileTab({ const commitRename = (): void => { if (renameCancelledRef.current) { - renameCancelledRef.current = false setIsRenaming(false) return } @@ -142,6 +141,9 @@ export default function EditorFileTab({ return } const newName = input.value.trim() + // onBlur follows Enter when the input unmounts; consume that trailing event + // so one user action cannot start a second rename against the old path. + renameCancelledRef.current = true setIsRenaming(false) if (!newName) { return diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts index 3f3756c0716..3dda2f83fec 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts @@ -25,6 +25,9 @@ export function projectIpcPtyConnectResult( ...(spawnResult.snapshotFrameRestoreAnsi !== undefined ? { snapshotFrameRestoreAnsi: spawnResult.snapshotFrameRestoreAnsi } : {}), + ...(spawnResult.snapshotTerminalOwner + ? { snapshotTerminalOwner: spawnResult.snapshotTerminalOwner } + : {}), isAlternateScreen: spawnResult.isAlternateScreen, sessionExpired: spawnResult.sessionExpired, coldRestore: spawnResult.coldRestore, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts index 0e130d0b00e..2a20be95575 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts @@ -1,6 +1,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_GRAPHIC_RENDITION @@ -220,6 +221,42 @@ describe('connectPanePty', () => { } }) + it('returns a shell-owned daemon snapshot to the normal buffer on reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: '\x1b[?1049h\x1b[?1003hSTALE-TUI', + isAlternateScreen: true, + snapshotTerminalOwner: 'shell' + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + }) + it('drops a too-wide daemon alt frame and keeps the scrollback prefix', async () => { // Why: fixed-grid alt rows clip at a narrower viewport until the owner repaints. const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts index 905ec2bf687..fe18699c20d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts @@ -1,8 +1,10 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, - POST_REPLAY_LIVE_SNAPSHOT_RESET + POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_REATTACH_RESET } from '../../../../shared/terminal-mode-reset-profiles' import { makePaneKey } from '../../../../shared/stable-pane-id' import { flushAsyncTicks } from './pty-connection-test-async' @@ -481,7 +483,8 @@ describe('connectPanePty', () => { getMainBufferSnapshot.mockResolvedValue({ data: 'agent-frame\x1b[?25l', cols: 100, - rows: 30 + rows: 30, + alternateScreen: true }) const paneKey = makePaneKey('tab-1', LEAF_1) const now = Date.now() @@ -524,12 +527,75 @@ describe('connectPanePty', () => { POST_REPLAY_LIVE_SNAPSHOT_RESET, expect.any(Function) ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) } finally { binding.dispose() } }) - it('resets cursor and focus modes on hidden-to-visible snapshot restore without a live agent', async () => { + it('lets fresh host shell proof outrank stale live-agent metadata on reveal', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'stale-agent-frame\x1b[?25l', + cols: 100, + rows: 30, + alternateScreen: true, + terminalOwner: 'shell' + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: '', + agentType: 'codex', + paneKey, + terminalTitle: 'codex', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + + const isVisibleRef = { current: false } + const pane = createPane(1) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ isVisibleRef, startup: { command: 'codex' } }) as never + ) + try { + await flushAsyncTicks(6) + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('preserves current behavior when a snapshot has no host ownership proof', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -544,7 +610,8 @@ describe('connectPanePty', () => { getMainBufferSnapshot.mockResolvedValue({ data: 'shell-frame\x1b[?25l', cols: 100, - rows: 30 + rows: 30, + alternateScreen: false }) const isVisibleRef = { current: false } @@ -570,6 +637,116 @@ describe('connectPanePty', () => { POST_REPLAY_LIVE_SNAPSHOT_RESET, expect.any(Function) ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('keeps a live alternate-screen pane interactive when a legacy snapshot omits its mode flag', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'legacy-tui-frame', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + pane.terminal.buffer.active.type = 'alternate' + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_SNAPSHOT_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('grounds a stale alternate-screen snapshot when the host proves shell ownership', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'stale-tui-frame', + cols: 100, + rows: 30, + alternateScreen: true, + terminalOwner: 'shell' + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + pane.terminal.buffer.active.type = 'alternate' + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ isVisibleRef, startup: { command: 'codex' } }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_SNAPSHOT_RESET, + expect.any(Function) + ) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() } finally { binding.dispose() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts index bf4d4a34feb..99c54dad2eb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts @@ -2,6 +2,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, @@ -380,6 +381,45 @@ describe('connectPanePty', () => { }) }) + it('lets fresh host shell proof outrank stale live-agent metadata on reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: '\x1b[?1049h\x1b[?1003hstale agent snapshot', + isAlternateScreen: true, + snapshotTerminalOwner: 'shell' as const + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const manager = createManager(1) + connectPanePty( + pane as never, + manager as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + it('ignores the stale agent signal on a cold restore and applies the fresh-shell reset', async () => { // Why: pane status and title are persisted, so after a cold restore they still // describe the process that died and make the pane look agent-owned. Preserving diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index ff7f5662b47..cb0806900af 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -92,7 +92,8 @@ export function createReattachPayloadHandlers( session.reattachReplayResetSequence( daemonSnapshotReplay, Boolean(ctx.connectResult.coldRestore), - ctx.connectResult.isAlternateScreen + ctx.connectResult.isAlternateScreen, + ctx.connectResult.snapshotTerminalOwner ) ) if (ctx.connectResult.pendingEscapeTailAnsi) { @@ -184,7 +185,8 @@ export function createReattachPayloadHandlers( session.reattachReplayResetSequence( modelData, Boolean(ctx.connectResult?.coldRestore), - modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen + modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen, + modelSnapshot.terminalOwner ) ) if (modelSnapshot.pendingEscapeTailAnsi) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 192dd042a84..28e0f95b9f7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -154,7 +154,9 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio kittyKeyboardFlags: meta.kittyKeyboardFlags, snapshotSeq: meta.snapshotSeq } - : {}) + : {}), + ...(meta.terminalOwner ? { terminalOwner: meta.terminalOwner } : {}), + ...(meta.alternateScreen !== undefined ? { alternateScreen: meta.alternateScreen } : {}) } session.scheduleReplayDataDrain() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts index e29842991c4..9784df28161 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts @@ -2,6 +2,7 @@ import { replayIntoTerminal, replayIntoTerminalAsync } from '../replay-guard' import { terminalOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script' import { buildPostReplayLiveAgentReattachReset, + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE @@ -133,7 +134,8 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void session.reattachReplayResetSequence = ( payload: string, ownerProcessEnded = false, - isAlternateScreen?: boolean + isAlternateScreen?: boolean, + terminalOwner?: 'shell' ): string => { // Why a cold restore overrides the agent signal: liveness is read from the // pane's status and title, both of which are persisted, so after a cold @@ -143,6 +145,11 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void if (ownerProcessEnded) { return POST_REPLAY_MODE_RESET } + if (terminalOwner === 'shell') { + return (isAlternateScreen ?? session.kittyKeyboardModes.isAlternateScreen) + ? POST_REPLAY_DEAD_TUI_RESET + : POST_REPLAY_REATTACH_RESET + } if (session.shouldPreserveAgentReattachModes()) { return buildPostReplayLiveAgentReattachReset(payload) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index 076c9194934..3d88cc34fa7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -4,6 +4,8 @@ import { waitForTerminalReplayWritesParsed } from '../replay-guard' import { POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_DEAD_TUI_RESET, + POST_REPLAY_REATTACH_RESET, RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles' import { @@ -51,6 +53,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): scrollbackAnsi?: string pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' }): Promise { const restorePtyId = session.transport.getPtyId() const restoreGeneration = session.hiddenOutputRestoreGeneration @@ -140,12 +143,20 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): session.writeReplayData(replayChunk) } } - // Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup). - session.writeReplayData( - session.hasLiveAgentReattachStatusOrTitleSignal() - ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET - : POST_REPLAY_LIVE_SNAPSHOT_RESET - ) + const hasLiveAgent = session.hasLiveAgentReattachStatusOrTitleSignal() + // Why the pane-local fallback: every owner-publishing host also fills + // alternateScreen, but the reattach site falls back to pane state for + // an absent flag and this site must not drift from it (?1049l on a + // normal-buffer pane still runs cursor restore). + const postReplayReset = + snapshot.terminalOwner === 'shell' + ? (snapshot.alternateScreen ?? session.isPaneOnAlternateScreen()) + ? POST_REPLAY_DEAD_TUI_RESET + : POST_REPLAY_REATTACH_RESET + : hasLiveAgent + ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET + : POST_REPLAY_LIVE_SNAPSHOT_RESET + session.writeReplayData(postReplayReset) if (snapshot.pendingEscapeTailAnsi) { // Why last: snapshot taken mid-escape; re-arm as the FINAL replay write (any later ESC aborts it) so the live tail completes it, not render literally (Bug E / #7329). session.writeReplayData(snapshot.pendingEscapeTailAnsi) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index 33ec09d79f6..87a2b6039ce 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -86,7 +86,8 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { return false } const payload = session.pendingReplayData - const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload + const { data, clearBeforeReplay, pendingEscapeTailAnsi, alternateScreen, terminalOwner } = + payload session.pendingReplayData = null const isCurrentPayload = (): boolean => !session.disposed && @@ -120,7 +121,9 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { continue } if (clearBeforeReplay || data.length > 0) { - await session.writeReplayDataAsync(session.reattachReplayResetSequence(data)) + await session.writeReplayDataAsync( + session.reattachReplayResetSequence(data, false, alternateScreen, terminalOwner) + ) if (!isCurrentPayload()) { continue } diff --git a/src/renderer/src/components/terminal-pane/pty-output-processor.ts b/src/renderer/src/components/terminal-pane/pty-output-processor.ts index 378e72a56c9..ac611e8ed0c 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-processor.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-processor.ts @@ -35,6 +35,8 @@ export type ProcessPtyOutputOptions = { pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number snapshotSeq?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } function removeSuppressedCursorNativeTitles( @@ -211,7 +213,11 @@ export function createPtyOutputProcessor({ ...(options.kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags: options.kittyKeyboardFlags } : {}), - ...(options.snapshotSeq !== undefined ? { snapshotSeq: options.snapshotSeq } : {}) + ...(options.snapshotSeq !== undefined ? { snapshotSeq: options.snapshotSeq } : {}), + ...(options.alternateScreen !== undefined + ? { alternateScreen: options.alternateScreen } + : {}), + ...(options.terminalOwner ? { terminalOwner: options.terminalOwner } : {}) } if (Object.keys(replayMeta).length > 0) { callbacks.onReplayData(data, replayMeta) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 17f390f4ec4..7db2a6e01f0 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -44,6 +44,7 @@ export type PtyBufferSnapshot = { /** Effective kitty flags the owner of this image proved at `seq`. Absent * means unknown; never rewrite that silence into a known `0`. */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } /** Metadata for one authoritative replay payload. */ @@ -56,6 +57,8 @@ export type PtyReplayDataMeta = { /** The boundary `kittyKeyboardFlags` describes, recorded as the renderer's * ordered high-water so a quiet pane can still publish a coherent snapshot. */ snapshotSeq?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } export type LocalPtySessionMetadata = { @@ -86,6 +89,7 @@ export type PtyConnectResult = { * domain `snapshotSeq` main reconciled for the same attach boundary. Absent * means unknown, never a proven inactive protocol. */ snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' snapshotSeq?: number isAlternateScreen?: boolean sessionExpired?: boolean diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts index 76f234c909c..709e1aa4875 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts @@ -97,7 +97,9 @@ describe('remote transport snapshot escape-tail threading (#7329)', () => { rows: 24, seq: 7, source: 'headless', - pendingEscapeTailAnsi: '\x1b[3' + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' }) }), encodeTerminalStreamFrame({ @@ -120,7 +122,11 @@ describe('remote transport snapshot escape-tail threading (#7329)', () => { await expect.poll(() => onReplayData.mock.calls.length, { timeout: 5000 }).toBeGreaterThan(0) expect(onReplayData).toHaveBeenCalledWith( 'user@host:~$ ', - expect.objectContaining({ pendingEscapeTailAnsi: '\x1b[3' }) + expect.objectContaining({ + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' + }) ) }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 5e0e42715ca..16c6b235eac 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1823,6 +1823,12 @@ export function createRemoteRuntimePtyTransport( kittyKeyboardFlags: meta.kittyKeyboardFlags, snapshotSeq: meta.seq } + : {}), + ...(meta?.terminalOwner && meta.seq !== undefined + ? { terminalOwner: meta.terminalOwner } + : {}), + ...(meta?.alternateScreen !== undefined && meta.seq !== undefined + ? { alternateScreen: meta.alternateScreen } : {}) }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index 3072b98dad2..ce6a3db257c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -311,4 +311,34 @@ describe('terminal replay state reset', () => { term.dispose() } }) + + it('disarms mouse input when a hidden replay lands on the normal shell buffer', async () => { + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await writeTerminal(term, '\x1b[?1003h\x1b[?1006h\x1b[?1049hTUI FRAME\x1b[?1049lSHELL MARKER') + expect(term.buffer.active.type).toBe('normal') + expect(term.modes.mouseTrackingMode).toBe('any') + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(term.buffer.active.type).toBe('normal') + expect(term.modes.mouseTrackingMode).toBe('none') + expect(term.buffer.active.getLine(0)?.translateToString()).toContain('SHELL MARKER') + } finally { + term.dispose() + } + }) + + it('preserves mouse input for a live alternate-screen replay', async () => { + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await writeTerminal(term, '\x1b[?1003h\x1b[?1006h\x1b[?1049hLIVE TUI') + await writeTerminal(term, POST_REPLAY_LIVE_SNAPSHOT_RESET) + + expect(term.buffer.active.type).toBe('alternate') + expect(term.modes.mouseTrackingMode).toBe('any') + } finally { + term.dispose() + } + }) }) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index d9783f85d40..b02f5b63870 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -67,6 +67,8 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { pendingEscapeTailAnsi?: string seq?: number kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } ) => void onSubscribed?: () => void @@ -96,6 +98,8 @@ export type RemoteRuntimeSnapshotImage = { * from any host that predates the field — the pane tracker then stays * unproven and commits raw text instead of guessing zero. */ kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } /** Transient causes the host itself reported: a request reached it and it declined to serialize now. */ @@ -210,6 +214,8 @@ type RemoteRuntimeSnapshotInfo = { seq?: number source?: 'headless' | 'renderer' kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' requestId?: number truncated?: boolean unavailable?: TerminalSnapshotUnavailableReason @@ -925,6 +931,8 @@ class RemoteRuntimeTerminalMultiplexer { seq: info?.seq, source: info?.source, kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner, pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi } }) @@ -933,7 +941,9 @@ class RemoteRuntimeTerminalMultiplexer { stream.callbacks.onSnapshot(data ?? '', { pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, seq: info?.seq, - kittyKeyboardFlags: info?.kittyKeyboardFlags + kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner }) } else if (target === 'recovery') { // Why: a server-pushed recovery snapshot replaces terminal state @@ -943,7 +953,9 @@ class RemoteRuntimeTerminalMultiplexer { stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, { pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, seq: info?.seq, - kittyKeyboardFlags: info?.kittyKeyboardFlags + kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner }) } } else if (matchesPendingRequest) { @@ -1600,6 +1612,8 @@ function decodeSnapshotInfo( unavailable?: unknown pendingEscapeTailAnsi?: unknown kittyKeyboardFlags?: unknown + alternateScreen?: unknown + terminalOwner?: unknown }>(payload) if (!raw) { return null @@ -1611,6 +1625,11 @@ function decodeSnapshotInfo( source: raw.source === 'headless' || raw.source === 'renderer' ? raw.source : undefined, // Negative, fractional, and unsafe values are treated as absent, never clamped. kittyKeyboardFlags: parseTerminalKittyKeyboardFlags(raw.kittyKeyboardFlags), + alternateScreen: + raw.terminalOwner === 'shell' && typeof raw.alternateScreen === 'boolean' + ? raw.alternateScreen + : undefined, + terminalOwner: raw.terminalOwner === 'shell' ? raw.terminalOwner : undefined, requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined, truncated: raw.truncated === true, unavailable: parseTerminalSnapshotUnavailableReason(raw.unavailable), diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index c11bd8fd43f..cd725c2d46f 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -41,7 +41,6 @@ src/main/browser/browser-cookie-import.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts src/main/claude-accounts/keychain.ts -src/main/codex-accounts/runtime-home-service.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-posix-supervisor.ts diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index 01a7f62acc3..69f176b9d80 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -23,6 +23,10 @@ export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x // Why: the normal-buffer fallback can follow a dead TUI, so its stale pen and saved pen must not reach the surviving shell. export const POST_REPLAY_REATTACH_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l${SAVE_GROUNDED_CURSOR}` +// Why: a foreground shell proves an alternate-screen owner died without its +// ?1049l cleanup; leave the renderer on the shell's normal buffer as well. +export const POST_REPLAY_DEAD_TUI_RESET = `\x1b[?1049l${POST_REPLAY_REATTACH_RESET}` + // Why: an alt-screen reattach replays the daemon's rehydrateSequences, which re-arm the live TUI's // mouse modes; wiping them one write later hands drags back to xterm's row selection (#8291). // Normal-buffer panes keep RESET_MOUSE_REPORTING so a dead TUI's stale modes never reach a shell (#7893). diff --git a/src/shared/terminal-owner.ts b/src/shared/terminal-owner.ts new file mode 100644 index 00000000000..8797e0dec3f --- /dev/null +++ b/src/shared/terminal-owner.ts @@ -0,0 +1,5 @@ +export type TerminalOwner = 'shell' + +export function parseTerminalOwner(value: unknown): TerminalOwner | undefined { + return value === 'shell' ? value : undefined +} diff --git a/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts index 1eba41b7fef..54d303ac871 100644 --- a/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts @@ -120,6 +120,11 @@ describe('cross-version remote terminal wire', () => { const record = await runTerminalSkewJourney({ hostBuild: current, clientBuild: current }) expectJourneyActuallyRan(record) expectWireCompatible(record) + expect(record.snapshotStarts).toEqual([ + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }) + ]) }, SUITE_TIMEOUT_MS ) @@ -131,6 +136,11 @@ describe('cross-version remote terminal wire', () => { expect(record.clientRevision).toBe(baseline.revision) expectJourneyActuallyRan(record) expectWireCompatible(record) + expect(record.snapshotStarts).toEqual([ + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }) + ]) }, SUITE_TIMEOUT_MS ) @@ -142,6 +152,10 @@ describe('cross-version remote terminal wire', () => { expect(record.hostRevision).toBe(baseline.revision) expectJourneyActuallyRan(record) expectWireCompatible(record) + for (const start of record.snapshotStarts) { + expect(start).not.toHaveProperty('terminalOwner') + expect(start).not.toHaveProperty('alternateScreen') + } }, SUITE_TIMEOUT_MS ) diff --git a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts index 1fc471b0de7..528e1a85874 100644 --- a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts +++ b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts @@ -89,9 +89,19 @@ export function createHostTerminalRuntimeStub( rows: number seq: number source: 'headless' + alternateScreen: false + terminalOwner: 'shell' }> => { stub.serializeCount++ - return { data: stub.buffer, cols, rows, seq: outputSequence, source: 'headless' } + return { + data: stub.buffer, + cols, + rows, + seq: outputSequence, + source: 'headless', + alternateScreen: false, + terminalOwner: 'shell' + } } const runtime: Record = { diff --git a/tests/e2e/floating-tab-rename.spec.ts b/tests/e2e/floating-tab-rename.spec.ts index 94efe4650ee..6f671789353 100644 --- a/tests/e2e/floating-tab-rename.spec.ts +++ b/tests/e2e/floating-tab-rename.spec.ts @@ -141,6 +141,37 @@ test('concurrent floating Markdown renames do not clobber the destination', asyn ).toEqual(['first\n', 'second\n']) }) +test('Enter commits a floating Markdown rename only once', async ({ orcaPage }) => { + const seeded = await seedFloatingMarkdownFile(orcaPage) + await openFloatingPanel(orcaPage) + + const panel = orcaPage.locator(OPEN_PANEL_SELECTOR) + const tab = panel.locator(`[data-tab-id="${seeded.tabId}"]`) + await tab.click({ button: 'right' }) + await orcaPage.getByRole('menuitem').filter({ hasText: 'Rename' }).first().click() + + const input = panel.getByRole('textbox', { + name: `Rename file ${seeded.originalName}`, + exact: true + }) + await input.fill(seeded.renamedName) + await input.press('Enter') + + await expect(tab).toContainText(seeded.renamedName) + await expect + .poll(() => + orcaPage.evaluate( + async ({ originalPath, renamedPath }) => ({ + originalExists: await window.api.fs.pathExists({ filePath: originalPath }), + renamedExists: await window.api.fs.pathExists({ filePath: renamedPath }) + }), + { originalPath: seeded.originalPath, renamedPath: seeded.renamedPath } + ) + ) + .toEqual({ originalExists: false, renamedExists: true }) + await expect(orcaPage.getByText(/Failed to rename/)).toHaveCount(0) +}) + test('Electron serializes native Unicode rename aliases', async ({ orcaPage }) => { test.skip(process.platform !== 'darwin', 'Requires native Unicode aliasing') const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) diff --git a/tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts b/tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts new file mode 100644 index 00000000000..c4cfc10a8ec --- /dev/null +++ b/tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts @@ -0,0 +1,220 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + execInTerminal, + getTerminalContent, + readPaneIdentitySnapshot, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + clearTerminalPtyWriteLog, + installTerminalPtyWriteSpy, + readTerminalPtyWriteEntries +} from './helpers/terminal-pty-write-spy' +import { stageNodeScriptForTerminal } from './helpers/run-node-script-in-terminal' + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveTab(id) + state?.setActiveTabType('terminal') + }, tabId) + await expect + .poll(() => + page.locator('[data-testid="sortable-tab"][data-active="true"]').getAttribute('data-tab-id') + ) + .toBe(tabId) +} + +for (const exitMode of ['normal', 'sigkill'] as const) { + test(`clears modes when a child TUI exits via ${exitMode} while its shell survives hidden`, async ({ + electronApp, + orcaPage + }) => { + await installTerminalPtyWriteSpy(electronApp) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const child = [ + "process.stdin.setRawMode?.(true); process.stdin.resume(); process.stdout.write('\\x1b[?1049h\\x1b[?1003h\\x1b[?1006h\\x1b[?25lCHILD_TUI_STARTED\\r\\n')", + ...(exitMode === 'normal' + ? [ + "process.on('SIGTERM', () => { process.stdout.write('\\x1b[?1006l\\x1b[?1003l\\x1b[?1049l\\x1b[?25h'); process.exit(0) })" + ] + : []), + "setInterval(() => process.stdout.write('\\x1b[2J\\x1b[HCHILD_TUI_FRAME\\r\\n'), 80)" + ].join(';') + const parent = [ + `const {spawn}=require('node:child_process'); const child=spawn(process.execPath,['-e',${JSON.stringify(child)}],{stdio:'inherit'});`, + "process.stdout.write('CHILD_TUI_PID_' + child.pid + '\\r\\n');", + "child.on('exit', () => process.stdout.write('\\r\\nCHILD_TUI_KILLED\\r\\n'))" + ].join(' ') + const command = stageNodeScriptForTerminal(parent, { prefix: 'orca-child-tui-kill' }).command + const tuiTabId = await orcaPage.evaluate( + ({ command }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (!state || !worktreeId) { + throw new Error('store/worktree unavailable') + } + const tab = state.createTab(worktreeId) + state.queueTabStartupCommand(tab.id, { command }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { command } + ) + + await expect.poll(() => getActiveTabId(orcaPage), { timeout: 5_000 }).toBe(tuiTabId) + const tuiPtyId = await waitForActivePanePtyId(orcaPage, 30_000) + const tuiIdentity = await readPaneIdentitySnapshot(orcaPage) + expect(tuiIdentity?.activeLeafId).not.toBeNull() + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 20_000 }) + .toContain('CHILD_TUI_FRAME') + await expect + .poll(async () => { + const match = (await getTerminalContent(orcaPage, 6_000)).match(/CHILD_TUI_PID_(\d+)/) + return match?.[1] ?? null + }) + .not.toBeNull() + const childPid = (await getTerminalContent(orcaPage, 6_000)).match(/CHILD_TUI_PID_(\d+)/)?.[1] + expect(childPid).toBeDefined() + await activateTerminalTab(orcaPage, shellTabId) + const shellPtyId = await waitForActivePanePtyId(orcaPage, 30_000) + const exitSignal = exitMode === 'normal' ? 'SIGTERM' : 'SIGKILL' + const killCommand = stageNodeScriptForTerminal(`process.kill(${childPid!}, '${exitSignal}')`, { + prefix: 'orca-child-tui-external-kill' + }).command + await execInTerminal(orcaPage, shellPtyId, killCommand) + await expect + .poll( + () => + orcaPage.evaluate(async (ptyId) => { + const processName = await window.api.pty.getForegroundProcess(ptyId) + return processName?.toLowerCase() ?? null + }, tuiPtyId), + { timeout: 8_000 } + ) + .toMatch(/^(bash|zsh|sh|fish)(\.exe)?$/) + await orcaPage.evaluate( + ({ paneKey, tabId }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (!state || !worktreeId) { + throw new Error('store/worktree unavailable') + } + state.setAgentStatus( + paneKey, + { state: 'working', prompt: 'stale hidden TUI status', agentType: 'codex' }, + 'Codex', + undefined, + { tabId, worktreeId } + ) + }, + { paneKey: `${tuiTabId}:${tuiIdentity!.activeLeafId!}`, tabId: tuiTabId } + ) + await clearTerminalPtyWriteLog(electronApp) + await activateTerminalTab(orcaPage, tuiTabId) + + const revealedPtyId = await orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return manager?.getActivePane?.()?.container?.dataset?.ptyId ?? null + }, tuiTabId) + expect(revealedPtyId).not.toBeNull() + await expect + .poll(async () => { + const snapshot = await orcaPage.evaluate( + (ptyId) => window.api.pty.getMainBufferSnapshot(ptyId, { scrollbackRows: 5000 }), + revealedPtyId! + ) + return snapshot?.terminalOwner === 'shell' && snapshot.alternateScreen === false + }) + .toBe(true) + await expect + .poll(() => + orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + return { + buffer: pane?.terminal.buffer.active.type, + mouse: pane?.terminal.modes.mouseTrackingMode + } + }, tuiTabId) + ) + .toEqual({ buffer: 'normal', mouse: 'none' }) + + // Why normal-exit only: the barrier preserves shell output from the 133;D + // boundary onward (the prompt), but bytes the dying command printed while + // the alternate screen was still up are part of the discarded dead frame. + // On SIGKILL the parent's exit line lands pre-D in the alt buffer, so it is + // unrecoverable by design; on normal exit the child's own ?1049l precedes + // it and it survives on the normal buffer. + if (exitMode === 'normal') { + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 8_000 }) + .toContain('CHILD_TUI_KILLED') + } + + const shellInputMarker = 'SHELL_INPUT_AFTER_TUI_KILL' + await execInTerminal(orcaPage, revealedPtyId!, `printf ${shellInputMarker}`) + await expect + .poll(async () => { + const writes = await readTerminalPtyWriteEntries(electronApp) + return writes.some( + (entry) => entry.id === revealedPtyId && entry.data.includes(shellInputMarker) + ) + }) + .toBe(true) + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 8_000 }) + .toContain(shellInputMarker) + + const terminalScreen = orcaPage.locator(`[data-pty-id="${revealedPtyId}"] .xterm-screen`) + await terminalScreen.hover({ position: { x: 20, y: 20 } }) + await orcaPage.mouse.wheel(0, 120) + await orcaPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) + + const ptyWrites = (await readTerminalPtyWriteEntries(electronApp)) + .filter((entry) => entry.id === revealedPtyId) + .map((entry) => entry.data) + const escape = String.fromCharCode(27) + expect(ptyWrites.some((data) => data.includes(`${escape}[<`))).toBe(false) + expect(ptyWrites.some((data) => data.includes(`${escape}[M`))).toBe(false) + + const terminalState = await orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + return { + buffer: pane?.terminal.buffer.active.type, + mouse: pane?.terminal.modes.mouseTrackingMode + } + }, tuiTabId) + expect(terminalState).toEqual({ buffer: 'normal', mouse: 'none' }) + expect(await getTerminalContent(orcaPage, 6_000)).not.toMatch(/\[<\d+;\d+;\d+[Mm]/) + + await activateTerminalTab(orcaPage, shellTabId) + expect(await waitForActivePanePtyId(orcaPage, 30_000)).toBe(shellPtyId) + const unrelatedMarker = 'UNRELATED_SHELL_STILL_LIVE' + await execInTerminal(orcaPage, shellPtyId, `printf ${unrelatedMarker}`) + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 8_000 }) + .toContain(unrelatedMarker) + }) +}