From f6e16ba3f8852764f059173a21d5b59365fd3106 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:02:09 -0700 Subject: [PATCH 1/8] fix(native-chat): anchor an unmatched chat echo where it was sent (#16117) * fix(native-chat): anchor an unmatched chat echo where it was sent The reported symptom was old user messages replaying below every new turn, so the conversation read as scrambled. The cause was not that the echo failed to match a transcript row. Claude consumes a mid-turn send through a `queued_command` attachment and writes no `type:"user"` record for it, so some echoes can never match, and no amount of matching will change that. The cause was WHERE an unmatched echo rendered: buildMobileNativeChatTransientData appended every pending item after the entire transcript, so it re-read below each turn that landed afterwards. Render each echo directly after the transcript row it was sent against, using the baseline the send already captures. An unmatched echo is then at worst a duplicate in the right position rather than a scrambled one, and it stays visible. Echoes sharing an anchor keep send order; a send with no baseline, or one whose anchor folding dropped, still falls back to the tail. Deliberately NOT fixed by deleting the echo. Inferring from send ordering that an echo can never match, then removing it, loses the user's own text for a message the agent did receive, and it cannot fire in the common case anyway - measured drain groups are 1,017 of size 1 against 55 larger. It also escalates an existing gap: the count pass has no baseline-tail guard, unlike the glue pass, while `messages` is a 40-row window that head-trims, resets on reconnect and grows at the front on loadEarlier, so a false landing there would license deleting a DIFFERENT outstanding message. That count-pass gap is real and left for a separate change; anchoring makes its worst case a duplicate in place rather than a scrambled conversation. * fix(native-chat): preserve folded echo anchors * fix(native-chat): preserve forward-folded echo anchors * fix(native-chat): keep leading folded echoes in place --- mobile/src/session/MobileNativeChatView.tsx | 3 +- .../mobile-native-chat-render-data.test.ts | 161 ++++++++++++++++++ .../session/mobile-native-chat-render-data.ts | 128 +++++++++++--- ...e-native-chat-drafts-glued-pending.test.ts | 1 + 4 files changed, 267 insertions(+), 26 deletions(-) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 190001417a3..2bd0e92b22c 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -181,12 +181,13 @@ export function MobileNativeChatView({ const { data } = useMemo( () => buildMobileNativeChatTransientData({ + messages, folded, streaming, pending, imagePreviewsByMessageId }), - [folded, streaming, pending, imagePreviewsByMessageId] + [messages, folded, streaming, pending, imagePreviewsByMessageId] ) // Follow the tail as the conversation grows and keep the newest message above diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 60db9e818d5..3c0281e24b8 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -65,6 +65,7 @@ function build( pending: Parameters[0]['pending'] ): NativeChatMessage[] { return buildMobileNativeChatTransientData({ + messages, folded: foldMobileNativeChatMessages(messages), streaming, pending @@ -143,6 +144,7 @@ describe('buildMobileNativeChatTransientData', () => { user('prompt', '[Image #1] look at this') ]) const result = buildMobileNativeChatTransientData({ + messages: folded, folded, streaming: null, pending: [], @@ -158,6 +160,7 @@ describe('buildMobileNativeChatTransientData', () => { it('restores the local preview onto a marker-only transcript turn', () => { const result = buildMobileNativeChatTransientData({ + messages: [user('prompt', '[Image #1]')], folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), streaming: null, pending: [], @@ -269,3 +272,161 @@ describe('foldMobileNativeChatMessages', () => { ]) }) }) + +// Claude consumes a mid-turn send through a `queued_command` attachment and writes +// no `type:"user"` record for it, so that echo has no row to match - ever. What +// made the conversation read as scrambled was not the unmatched echo itself but +// where it rendered: appended after every turn, so it re-read below each new one. +describe('buildMobileNativeChatTransientData anchoring', () => { + function row(id: string, role: 'user' | 'assistant', text: string): NativeChatMessage { + return { id, role, blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' } + } + + it('keeps an unmatched echo where it was sent instead of below later turns', () => { + const folded = [row('m1', 'user', 'earlier'), row('m2', 'assistant', 'on it')] + const { data } = buildMobileNativeChatTransientData({ + messages: folded, + folded, + streaming: null, + pending: [{ id: 'p1', text: 'a mid-turn send', baselineTailMessageId: 'm2' }] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'm2', 'p1']) + + // The agent keeps working. The echo must NOT drift below the new turns. + const later = [ + ...folded, + row('m3', 'assistant', 'still working'), + row('m4', 'assistant', 'done') + ] + const { data: after } = buildMobileNativeChatTransientData({ + messages: later, + folded: later, + streaming: null, + pending: [{ id: 'p1', text: 'a mid-turn send', baselineTailMessageId: 'm2' }] + }) + expect(after.map((m) => m.id)).toEqual(['m1', 'm2', 'p1', 'm3', 'm4']) + }) + + it('reproduces the reported replay: stale echoes stay in place, not stacked at the tail', () => { + const folded = [ + row('m1', 'user', 'first question'), + row('m2', 'assistant', 'answering'), + row('m3', 'assistant', 'newest turn') + ] + const { data } = buildMobileNativeChatTransientData({ + messages: folded, + folded, + streaming: null, + pending: [ + { id: 'p1', text: 'sent against m1', baselineTailMessageId: 'm1' }, + { id: 'p2', text: 'sent against m2', baselineTailMessageId: 'm2' } + ] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'm2', 'p2', 'm3']) + }) + + it('keeps send order among echoes sharing one anchor', () => { + const { data } = buildMobileNativeChatTransientData({ + messages: [row('m1', 'assistant', 'ready')], + folded: [row('m1', 'assistant', 'ready')], + streaming: null, + pending: [ + { id: 'p1', text: 'first', baselineTailMessageId: 'm1' }, + { id: 'p2', text: 'second', baselineTailMessageId: 'm1' } + ] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'p2']) + }) + + it('falls back to the tail when the send captured no baseline', () => { + const { data } = buildMobileNativeChatTransientData({ + messages: [row('m1', 'assistant', 'ready')], + folded: [row('m1', 'assistant', 'ready')], + streaming: null, + pending: [{ id: 'p1', text: 'no baseline yet', baselineTailMessageId: null }] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'p1']) + }) + + it('falls back to the tail when the captured row left the raw window', () => { + const { data } = buildMobileNativeChatTransientData({ + messages: [row('m1', 'assistant', 'ready')], + folded: [row('m1', 'assistant', 'ready')], + streaming: null, + pending: [{ id: 'p1', text: 'anchored to a folded-away row', baselineTailMessageId: 'gone' }] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'p1']) + }) + + it('keeps an echo before later turns when its folded baseline leads the raw window', () => { + const messages = [ + row('noise', 'user', 'hidden boundary'), + row('a1', 'assistant', 'arrived later') + ] + const folded = foldMobileNativeChatMessages(messages) + expect(folded.map((message) => message.id)).toEqual(['a1']) + + const { data } = buildMobileNativeChatTransientData({ + messages, + folded, + streaming: null, + pending: [ + { id: 'p1', text: 'sent after the hidden boundary', baselineTailMessageId: 'noise' } + ] + }) + expect(data.map((message) => message.id)).toEqual(['p1', 'a1']) + }) + + it('still puts the streaming bubble after the transcript', () => { + const { data } = buildMobileNativeChatTransientData({ + messages: [row('m1', 'user', 'hi')], + folded: [row('m1', 'user', 'hi')], + streaming: 'thinking', + pending: [{ id: 'p1', text: 'echo', baselineTailMessageId: 'm1' }] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'streaming']) + }) + + it('anchors a send captured against a tool row to its folded assistant', () => { + const messages: NativeChatMessage[] = [ + row('a1', 'assistant', 'working'), + { + id: 'tool', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'pnpm test' } }], + timestamp: 2, + source: 'transcript' + }, + row('a2', 'assistant', 'done') + ] + const folded = foldMobileNativeChatMessages(messages) + expect(folded.map((message) => message.id)).toEqual(['a1', 'a2']) + + const { data } = buildMobileNativeChatTransientData({ + messages, + folded, + streaming: null, + pending: [{ id: 'p1', text: 'sent during the tool', baselineTailMessageId: 'tool' }] + }) + expect(data.map((message) => message.id)).toEqual(['a1', 'p1', 'a2']) + }) + + it('anchors after the prompt that absorbed an earlier image-source row', () => { + const messages = [ + row('a1', 'assistant', 'ready'), + user('source', '[Image: source: /tmp/earlier.png]'), + user('prompt', '[Image #1] earlier image'), + row('a2', 'assistant', 'done') + ] + const folded = foldMobileNativeChatMessages(messages) + expect(folded.map((message) => message.id)).toEqual(['a1', 'prompt', 'a2']) + + const { data } = buildMobileNativeChatTransientData({ + messages, + folded, + streaming: null, + pending: [{ id: 'p1', text: 'sent after the image source', baselineTailMessageId: 'source' }] + }) + expect(data.map((message) => message.id)).toEqual(['a1', 'prompt', 'p1', 'a2']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 92b7e6b8338..da93efadbb8 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -6,7 +6,10 @@ import { import { stripNoiseMessages } from '../../../src/shared/native-chat-noise' import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' +import { + isImageSourceUserTurn, + normalizeImageTranscriptMessages +} from './mobile-native-chat-image-transcript-markers' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' /** The centered empty-state copy for a chat with no messages, mirroring the @@ -42,6 +45,10 @@ export type MobileNativeChatPendingItem = { id: string text: string images?: string[] + /** Transcript tail when the send was issued. The echo renders directly after + * that row, so a send whose row never arrives stays where it was sent instead + * of trailing every turn that lands afterwards. */ + baselineTailMessageId?: string | null } export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { @@ -50,15 +57,16 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat return stripNoiseMessages(foldToolMessages(normalizeImageTranscriptMessages(messages))) } -/** Assemble the list data the chat renders: the folded transcript, then a - * synthetic bubble for the streaming text the gate let through, then the - * route-owned accepted optimistic messages at the tail. */ +/** Assemble the folded transcript, streaming text, and optimistic user echoes. */ export function buildMobileNativeChatTransientData({ + messages, folded, streaming, pending, imagePreviewsByMessageId }: { + /** Raw transcript rows, used to project folded-away send boundaries. */ + messages: NativeChatMessage[] folded: NativeChatMessage[] /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ streaming: string | null @@ -85,31 +93,101 @@ export function buildMobileNativeChatTransientData({ } return { ...message, blocks } }) - const data: NativeChatMessage[] = [ - ...renderedFolded, - ...(streaming - ? [ - { - id: 'streaming', - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: streaming }], - timestamp: null, - source: 'hook' as const - } - ] - : []), - ...pending.map((p) => ({ - id: p.id, - role: 'user' as const, + // Why anchored rather than appended: an echo whose transcript row never + // arrives — Claude consumes a mid-turn send without writing a user record — + // used to sit at the tail forever, re-reading below every turn that landed + // afterwards. That is what makes the conversation look re-ordered. Rendering it + // after the row it was sent against keeps it in place, so an unmatched echo is + // at worst a duplicate in the right position instead of a scrambled one. + const anchoredPending = new Map() + const leadingPending: NativeChatMessage[] = [] + const trailingPending: NativeChatMessage[] = [] + const foldedIds = new Set(renderedFolded.map((message) => message.id)) + const missingBaselineIds = new Set() + for (const item of pending) { + const baselineId = item.baselineTailMessageId + if (baselineId && !foldedIds.has(baselineId)) { + missingBaselineIds.add(baselineId) + } + } + const foldedAnchorByRawId = new Map() + const leadingBaselineIds = new Set() + if (missingBaselineIds.size > 0) { + let lastVisibleId: string | null = null + const forwardImageBaselineIds: string[] = [] + for (const message of messages) { + if (foldedIds.has(message.id)) { + for (const baselineId of forwardImageBaselineIds) { + foldedAnchorByRawId.set(baselineId, message.id) + } + forwardImageBaselineIds.length = 0 + lastVisibleId = message.id + } + if (!missingBaselineIds.has(message.id)) { + continue + } + if (isImageSourceUserTurn(message)) { + forwardImageBaselineIds.push(message.id) + } else if (lastVisibleId) { + foldedAnchorByRawId.set(message.id, lastVisibleId) + } else { + leadingBaselineIds.add(message.id) + } + } + } + for (const item of pending) { + const bubble: NativeChatMessage = { + id: item.id, + role: 'user', // Text first (when present), then a thumbnail per ridden-along image so the // sent photo shows immediately, before the transcript echo lands. blocks: [ - ...(p.text ? [{ type: 'text' as const, text: p.text }] : []), - ...(p.images ?? []).map((uri) => ({ type: 'image-ref' as const, url: uri })) + ...(item.text ? [{ type: 'text' as const, text: item.text }] : []), + ...(item.images ?? []).map((uri) => ({ type: 'image-ref' as const, url: uri })) ], timestamp: null, - source: 'transcript' as const - })) - ] + source: 'transcript' + } + // Tool/noise rows fold backward; image-source rows fold into their following prompt. + const baselineId = item.baselineTailMessageId + if (baselineId && leadingBaselineIds.has(baselineId)) { + leadingPending.push(bubble) + continue + } + const anchor = baselineId + ? foldedIds.has(baselineId) + ? baselineId + : foldedAnchorByRawId.get(baselineId) + : undefined + if (!anchor || !foldedIds.has(anchor)) { + trailingPending.push(bubble) + continue + } + const siblings = anchoredPending.get(anchor) + if (siblings) { + siblings.push(bubble) + } else { + anchoredPending.set(anchor, [bubble]) + } + } + + const data: NativeChatMessage[] = [...leadingPending] + for (const message of renderedFolded) { + data.push(message) + const attached = anchoredPending.get(message.id) + if (attached) { + data.push(...attached) + } + } + if (streaming) { + data.push({ + id: 'streaming', + role: 'assistant', + blocks: [{ type: 'text', text: streaming }], + timestamp: null, + source: 'hook' + }) + } + data.push(...trailingPending) return { folded: renderedFolded, streaming, data } } diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index b5d1f92ec82..008119dae5e 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -41,6 +41,7 @@ function renderedPendingTexts( state: DraftState | null ): (string | undefined)[] { const { data } = buildMobileNativeChatTransientData({ + messages, folded: messages, streaming: null, pending: state?.pending ?? [] From cd5be48a34031623a8b5d3cc210bd6b048c23052 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:23:04 -0700 Subject: [PATCH 2/8] fix(workspace-cleanup): show git status for every row (#16690) --- .../workspace-cleanup/WorkspaceCleanupDialog.tsx | 9 ++++----- .../use-workspace-cleanup-git-evidence.ts | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 44c71f00b0f..ad14bc6995f 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -29,10 +29,7 @@ import { } from './workspace-cleanup-dialog-notices' import { WorkspaceCleanupRowList } from './workspace-cleanup-row-list' import { formatWorkspaceCleanupScanNotice } from './workspace-cleanup-scan-notice' -import { - applyWorkspaceCleanupGitEvidence, - needsWorkspaceCleanupGitEvidence -} from './workspace-cleanup-git-evidence' +import { applyWorkspaceCleanupGitEvidence } from './workspace-cleanup-git-evidence' import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state' import { useWorkspaceCleanupDialogLifecycle, @@ -113,7 +110,9 @@ function WorkspaceCleanupDialogContent({ ) const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) - const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort) + // The browse view promises Git facts for every row. The broad scan defers + // some reads for performance, so fill those gaps while the modal is open. + const gitEvidenceNeeded = open const gitEvidence = useWorkspaceCleanupGitEvidence({ enabled: gitEvidenceNeeded, candidates, diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts index 9aa9d53a6d7..ffd41665523 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts @@ -23,9 +23,9 @@ const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = { } /** - * Fills in git evidence the broad scan deferred with one targeted batch scan. - * Only runs while a git-dependent filter or sort is active — the initial list - * render never waits on it, so an unfiltered browse stays instant. + * Fills in git evidence the broad scan deferred with targeted batch scans. + * The initial list render never waits on these requests, so the browse stays + * responsive while each row transitions from an unknown status to its result. */ export function useWorkspaceCleanupGitEvidence({ enabled, From 3c79779ae0a884b8459245969230bbd8b3f439d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:00:49 +0000 Subject: [PATCH 3/8] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From 80246238fcc73ee2e9365c18b9830b7b7fbf9280 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:17:31 -0700 Subject: [PATCH 4/8] fix(mobile): reset recycled paragraph layout before reuse (#16692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): quantize chat pinch font scale so a zoom stops re-measuring the list every frame A user bubble on a 390pt iPhone painted five lines inside a frame that reserved six, with the last painted line cut through a glyph at the content edge and "no longer needed." gone. The paint is React Native's: a `` with no `numberOfLines` gets a text container whose `lineBreakMode` is `NSLineBreakByClipping` (RCTTextLayoutManager.mm). Measure lays out into `{width, CGFLOAT_MAX}`, paint lays out into the mounted content frame — so a frame one line short does not re-wrap, it dumps the remainder onto the last fitting line and clips it, with no ellipsis. Reproduced on-device against the real component with the message text held constant, so five painted lines can only be truncation. Two conditions are each necessary, and removing either makes it vanish over ~6000 measured bubble renders: a pooled `RCTParagraphComponentView` carrying a shorter row's content frame (`prepareForRecycle` clears `state` but not `_textView.layoutMetrics`), and whole-list re-measure churn while rows enter and leave that pool. The churn was ours. `renderItem` closes over `fontScale`, and the pinch handler committed a new scale on every gesture frame, so one zoom drove hundreds of full-list re-measures. The pinch is composed `Simultaneous` with the list's own scroll, so a stray second finger during a scroll started that storm at scales the user cannot see — matching the report, whose glyph metrics are `fontScale` 1.0 exactly. `quantizeFontScale` snaps commits to a 5% grid. React bails out of a same-value `setState`, so gesture noise now commits nothing and a full-range pinch commits at most ~20 times. Under the churn that produced 67 defects in 6636 bubble renders, the quantized build measured 0 in 6064 — with a forced-defect control bubble flagged in 100% of frames of both runs to prove the detector was live. This removes the trigger we own; it does not close the RN recycling window itself. That needs a one-line reset in `prepareForRecycle`, which cannot land here without refreshing the `patchedDependencies` hash under `mobile/`. * fix(mobile): reset recycled paragraph layout before reuse --- mobile/patches/react-native@0.83.9.patch | 12 +++ mobile/pnpm-lock.yaml | 2 +- .../mobile-native-chat-message-text.ts | 25 +++++ ...e-mobile-native-chat-pinch-gesture.test.ts | 91 +++++++++++++++++++ .../use-mobile-native-chat-pinch-gesture.ts | 8 +- 5 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts 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)) }) ), [] From d60a3c900b0cfe63611a6f0a2ab0e41cc438939d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:49:26 -0700 Subject: [PATCH 5/8] Reset stale terminal modes after dead TUI replay (#16379) --- config/reliability-gates.jsonc | 123 ++++++ src/main/daemon/daemon-checkpoint-file.ts | 2 + ...mon-durable-history-ownership-seed.test.ts | 41 ++ .../daemon/daemon-durable-history-snapshot.ts | 34 ++ .../daemon-foreground-process-protocol.ts | 4 + .../daemon/daemon-pty-adapter-test-harness.ts | 2 + src/main/daemon/daemon-pty-adapter.test.ts | 31 ++ .../daemon/daemon-pty-buffer-snapshots.ts | 3 +- .../daemon/daemon-pty-process-inspection.ts | 11 + src/main/daemon/daemon-pty-router.ts | 4 + src/main/daemon/daemon-pty-spawn-result.ts | 3 + src/main/daemon/daemon-request-router.ts | 8 +- .../daemon-restore-scrollback-depth.test.ts | 33 ++ .../daemon/degraded-daemon-pty-provider.ts | 3 + .../foreground-process-tracker.ts | 6 +- .../pty-shell-foreground-confirmation.ts | 24 ++ .../pty-subprocess/subprocess-handle.ts | 1 + src/main/daemon/session-output-pipeline.ts | 33 ++ src/main/daemon/session-output-plane.ts | 39 +- .../daemon/session-shell-recovery.test.ts | 156 +++++++ src/main/daemon/session-subprocess-handle.ts | 2 + src/main/daemon/session.test.ts | 50 +++ src/main/daemon/session.ts | 68 +-- .../terminal-checkpoint-serializer.test.ts | 19 + .../daemon/terminal-checkpoint-serializer.ts | 8 +- .../terminal-history-checkpoint-reader.ts | 5 +- .../terminal-history-cold-restore-info.ts | 6 +- .../terminal-host-concurrent-create.test.ts | 50 ++- .../daemon/terminal-host-session-create.ts | 6 + src/main/daemon/terminal-host.ts | 26 ++ .../terminal-shell-clean-exit-confirmation.ts | 100 +++++ .../terminal-shell-lifecycle-scanner.test.ts | 285 ++++++++++++ .../terminal-shell-lifecycle-scanner.ts | 223 ++++++++++ .../terminal-shell-recovery-barrier.test.ts | 405 ++++++++++++++++++ .../daemon/terminal-shell-recovery-barrier.ts | 338 +++++++++++++++ ...ell-recovery-clean-exit-retirement.test.ts | 72 ++++ src/main/daemon/terminal-snapshot.ts | 3 + src/main/daemon/types.ts | 3 + src/main/ipc/pty/ipc/snapshot.ts | 1 + src/main/ipc/pty/ipc/spawn-commit.ts | 14 +- src/main/ipc/pty/runtime/controller.ts | 2 + src/main/ipc/pty/runtime/operations.ts | 8 + ...ound-process-windows-job-rejection.test.ts | 21 + .../agent-foreground-process.test.ts | 101 +++++ .../providers/agent-foreground-process.ts | 63 +++ src/main/providers/local-pty-provider.ts | 20 +- src/main/providers/pty-provider-contract.ts | 5 + src/main/providers/pty-spawn-result.ts | 3 + src/main/providers/ssh-pty-provider.test.ts | 7 + ...bile-session-tabs-churn-coalescing.test.ts | 179 ++++++++ .../mobile-subscribe-integration.test.ts | 108 +---- src/main/runtime/orca-runtime.test.ts | 48 ++- src/main/runtime/orca-runtime.ts | 54 ++- .../pty-shell-ownership-mirror.test.ts | 88 ++++ .../runtime/pty-shell-ownership-mirror.ts | 149 +++++++ .../terminal-legacy-subscribe-snapshot.ts | 2 + .../terminal-multiplex-flow-control.ts | 2 + .../terminal-multiplex-initial-snapshot.ts | 2 + .../terminal-multiplex-slot-frames.ts | 2 + .../terminal/terminal-snapshot-publication.ts | 11 + .../methods/terminal/terminal-stream-types.ts | 4 + .../terminal-multiplex-escape-tail.test.ts | 9 +- .../rpc/terminal-subscribe-buffer.test.ts | 8 +- src/preload/api/pty-api.ts | 2 + src/preload/index.ts | 2 + .../terminal-pane/ipc-pty-connect-result.ts | 3 + ...-connection-daemon-snapshot-replay.test.ts | 37 ++ ...ty-connection-hidden-codex-queries.test.ts | 185 +++++++- ...pty-connection-reattach-mode-reset.test.ts | 40 ++ .../pty-connection/apply-reattach-payload.ts | 6 +- .../deferred-cold-restore-and-snapshot.ts | 4 +- .../fresh-spawn-follow-reset.ts | 9 +- .../hidden-output-restore-snapshot.ts | 23 +- .../pty-connection/replay-data-drain.ts | 7 +- .../terminal-pane/pty-output-processor.ts | 8 +- .../terminal-pane/pty-transport-types.ts | 4 + ...e-runtime-pty-snapshot-escape-tail.test.ts | 10 +- .../remote-runtime-pty-transport.ts | 6 + .../terminal-replay-cursor-state.test.ts | 30 ++ .../remote-runtime-terminal-multiplexer.ts | 23 +- src/shared/terminal-mode-reset-profiles.ts | 4 + src/shared/terminal-owner.ts | 5 + .../cross-version-terminal-wire.unit.test.ts | 14 + .../host-terminal-runtime-stub.ts | 12 +- ...l-hidden-child-tui-kill-mode-reset.spec.ts | 220 ++++++++++ 85 files changed, 3612 insertions(+), 183 deletions(-) create mode 100644 src/main/daemon/daemon-durable-history-ownership-seed.test.ts create mode 100644 src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts create mode 100644 src/main/daemon/session-output-pipeline.ts create mode 100644 src/main/daemon/session-shell-recovery.test.ts create mode 100644 src/main/daemon/terminal-shell-clean-exit-confirmation.ts create mode 100644 src/main/daemon/terminal-shell-lifecycle-scanner.test.ts create mode 100644 src/main/daemon/terminal-shell-lifecycle-scanner.ts create mode 100644 src/main/daemon/terminal-shell-recovery-barrier.test.ts create mode 100644 src/main/daemon/terminal-shell-recovery-barrier.ts create mode 100644 src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts create mode 100644 src/main/providers/agent-foreground-process-windows-job-rejection.test.ts create mode 100644 src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts create mode 100644 src/main/runtime/pty-shell-ownership-mirror.test.ts create mode 100644 src/main/runtime/pty-shell-ownership-mirror.ts create mode 100644 src/shared/terminal-owner.ts create mode 100644 tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts 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/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 4fce4853bc4..ff5a0e5ccd0 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, @@ -55,6 +56,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 745f225312a..bf3ca74af25 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -114,6 +114,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 a1e02d02fc5..76de68fa0e8 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/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 ea21fda6fff..60926fc873c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11813,6 +11813,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', @@ -11839,6 +11883,7 @@ describe('OrcaRuntimeService', () => { } outputSequence: number writeChain: Promise + ownership: { settle: () => Promise; owner: undefined } } const runtimePrivate = runtime as unknown as { headlessTerminals: Map @@ -11849,7 +11894,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 593a5f1db41..b8196482870 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1199,6 +1199,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, @@ -1863,6 +1864,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<{ @@ -1916,6 +1918,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 = { @@ -1927,6 +1930,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 = { @@ -2022,6 +2026,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 @@ -13150,6 +13155,7 @@ export class OrcaRuntimeService { oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean scrollbackAnsi?: string + terminalOwner?: 'shell' } | null> { return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) } @@ -13170,6 +13176,7 @@ export class OrcaRuntimeService { alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string + terminalOwner?: 'shell' } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, @@ -13270,6 +13277,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(() => { @@ -13377,6 +13390,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) @@ -13452,6 +13468,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 }) @@ -13511,7 +13530,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 } @@ -13563,6 +13603,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(() => { @@ -13621,6 +13664,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 @@ -14101,6 +14145,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). @@ -14111,11 +14156,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, { @@ -14138,10 +14184,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). @@ -14161,6 +14208,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/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 9efc4716019..d36b385d10a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1057,6 +1057,7 @@ const api = { snapshotFrameAnsi?: string snapshotFrameRestoreAnsi?: string snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' snapshotSeq?: number isReattach?: boolean isAlternateScreen?: boolean @@ -1179,6 +1180,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/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/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/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) + }) +} From ebcd637db981b0ce272c8e183794a8fc7caaa7be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:22:05 -0700 Subject: [PATCH 6/8] fix(codex): launch WSL accounts from direct homes (#16504) * fix(codex): launch WSL accounts from direct homes * fix(codex): coalesce WSL auth drains and validate distro homes * fix(codex): preserve legacy WSL account home metadata * fix(codex): retain marked WSL home compatibility * fix(codex): verify the bytes the WSL drain promotes, not an earlier read The apply script validated the source hash and then re-read it with cp, so a legacy pane rotating in that window put bytes freshness never judged over a valid account home. Codex rewrites auth.json in place, so that read can be torn. Covers it by running the real guest script under sh with a sha256sum shim that rotates the source between the two reads; without the guard it exits 0. * fix(codex): harden WSL auth drain races --- ...sl-runtime-auth-drain-apply-script.test.ts | 170 ++++++++++ .../legacy-wsl-runtime-auth-drain.test.ts | 212 ++++++++++++ .../legacy-wsl-runtime-auth-drain.ts | 315 ++++++++++++++++++ .../codex-accounts/runtime-home-service.ts | 197 +++++++++-- .../runtime-home-wsl-managed-accounts.test.ts | 71 ++-- .../runtime-home-wsl-session-bridge.test.ts | 62 ++-- .../runtime-home-wsl-system-default.test.ts | 37 +- .../wsl-codex-auth-batch-reader.ts | 70 ++++ src/main/codex/codex-pane-account-registry.ts | 9 + .../codex/codex-pane-launch-account.test.ts | 2 +- src/main/codex/codex-pane-launch-account.ts | 17 +- .../codex/codex-stale-pane-accounts.test.ts | 31 ++ 12 files changed, 1043 insertions(+), 150 deletions(-) create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts create mode 100644 src/main/codex-accounts/wsl-codex-auth-batch-reader.ts 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..cc50be185af --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -0,0 +1,315 @@ +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): void { + const key = options.distro.trim().toLowerCase() + if (completedDistroKeys.has(key)) { + return + } + // 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. + if (drainQueueByDistro.has(key)) { + return + } + 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) + } + }) +} + +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..e9c0b9a2e0d 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -61,6 +61,7 @@ import { } from '../codex/codex-config-mirror' import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { + getCodexSelectionLaneKey, getWslSelectionKey, getSelectedCodexAccountIdForTarget, normalizeCodexRuntimeSelection, @@ -91,10 +92,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 @@ -244,11 +251,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) { @@ -790,10 +797,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 +839,7 @@ export class CodexRuntimeHomeService { launchEnv?: NodeJS.ProcessEnv ): void { if (target?.runtime === 'wsl') { - this.syncWslRuntimeForCurrentSelection(target) + this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) return } @@ -1051,28 +1057,128 @@ 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) + this.startLegacyWslAuthDrain(target) + return this.getWslCodexHomePathForSelection(target) } - private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { + private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, target) + ) + 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): void { + if (process.platform !== 'win32') { + return + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const guestHome = getWslHome(distro) + const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null + if (!parsedGuestHome) { + return + } + let legacyPanePresent: boolean + try { + legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) + } catch (error) { + console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) + return + } + startLegacyWslRuntimeAuthDrain({ + distro, + guestHomeLinuxPath: parsedGuestHome.linuxPath, + legacyPanePresent, + resolveDestination: (runtimeAuthContents) => + this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) + }) + } + + 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 + } + + syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null } @@ -1319,7 +1425,11 @@ export class CodexRuntimeHomeService { private findManagedAccountForRuntimeAuth( runtimeAuthContents: string, - expectedAccountId?: string + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } ): CodexReadBackMatch { const matches: { account: CodexManagedAccount @@ -1327,18 +1437,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 +1460,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 }) } 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..95228744bab 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) @@ -255,7 +248,7 @@ describe('CodexRuntimeHomeService', () => { ).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 +310,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) @@ -413,10 +407,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) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -476,7 +470,8 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) + mkdirSync(wslRuntimeHomePath, { recursive: true }) writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') @@ -484,7 +479,7 @@ describe('CodexRuntimeHomeService', () => { service.syncForCurrentSelection(target) expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(staleRuntimeAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -492,7 +487,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('reads active WSL token refreshes back before restart using the selected distro', async () => { + it('keeps direct-home WSL token refreshes canonical across restart preservation', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -548,13 +543,13 @@ describe('CodexRuntimeHomeService', () => { ) const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) + writeFileSync(managedAuthPath, refreshedAuth, 'utf-8') service.syncActiveWslSelectionsBeforeRestart() expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(existsSync(runtimeAuthPath)).toBe(false) } 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..33eae33977f 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' @@ -89,7 +89,7 @@ describe('CodexRuntimeHomeService', () => { } }) - 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 +124,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 +140,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 +173,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 +220,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') @@ -287,7 +262,8 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate === wslRuntimeHomePath + candidate === wslRuntimeHomePath || + candidate.includes('codex-accounts/debian-account/home') ? { distro: 'Debian', linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' @@ -328,12 +304,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..dbf0241a155 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 @@ -93,15 +93,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 +138,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 +219,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 +248,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 +260,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 +300,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', From 9fb522023979c0d56761782be024c7630f6046f1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:17:59 -0700 Subject: [PATCH 7/8] Prevent duplicate file renames when input unmounts after Enter (#16719) When Enter is pressed to confirm a rename, the input unmounts and its onBlur handler fires as it detaches from the DOM. Without consuming this event, a second commitRename call would attempt to rename against the old path. Setting the cancel flag after capturing the new name causes the trailing onBlur to return early, preventing the duplicate operation. --- .../editor/editor-header-file-rename.ts | 4 ++- .../components/tab-bar/EditorFileTab.test.tsx | 24 ++++++++++++++ .../src/components/tab-bar/EditorFileTab.tsx | 4 ++- tests/e2e/floating-tab-rename.spec.ts | 31 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) 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/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()) From 673842db35b37557006d9f20d04e077780253d82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:24:32 -0700 Subject: [PATCH 8/8] refactor(codex): remove WSL runtime mirror machinery (#16505) * refactor(codex): remove WSL runtime mirror machinery * test(codex): drop allowlist entries the mirror removal made stale runtime-home-service.ts no longer spawns wsl.exe or imports child_process; both boundary guards fail closed on a stale entry so the goalpost keeps moving. * fix(codex): drain legacy WSL auth before restart * fix(codex): await WSL auth drain before restart --- .../agent-auth-restart-preservation.test.ts | 126 ++++-- src/main/agent-auth-restart-preservation.ts | 49 +-- .../legacy-wsl-runtime-auth-drain.ts | 12 +- .../codex-accounts/runtime-home-service.ts | 390 ++---------------- .../runtime-home-wsl-managed-accounts.test.ts | 182 +------- .../runtime-home-wsl-session-bridge.test.ts | 68 +-- .../runtime-home-wsl-system-default.test.ts | 10 - src/main/pty/codex-home-wsl-env.ts | 4 +- .../__fixtures__/wsl-invocation-allowlist.txt | 1 - .../child-process-import-allowlist.txt | 1 - 10 files changed, 164 insertions(+), 679 deletions(-) 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.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts index cc50be185af..bde5d4c9ebc 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -31,16 +31,19 @@ type LegacyWslRuntimeAuthDrainOptions = { const drainQueueByDistro = new Map>() const completedDistroKeys = new Set() -export function startLegacyWslRuntimeAuthDrain(options: LegacyWslRuntimeAuthDrainOptions): void { +export function startLegacyWslRuntimeAuthDrain( + options: LegacyWslRuntimeAuthDrainOptions +): Promise { const key = options.distro.trim().toLowerCase() if (completedDistroKeys.has(key)) { - return + 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. - if (drainQueueByDistro.has(key)) { - return + const inFlight = drainQueueByDistro.get(key) + if (inFlight) { + return inFlight } const next = drainLegacyWslRuntimeAuth(options) .then((status) => { @@ -57,6 +60,7 @@ export function startLegacyWslRuntimeAuthDrain(options: LegacyWslRuntimeAuthDrai drainQueueByDistro.delete(key) } }) + return next } export async function drainLegacyWslRuntimeAuth( diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index e9c0b9a2e0d..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,17 +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' @@ -137,7 +130,6 @@ type CodexRuntimeLogoutMarkerStatus = | { kind: 'applies' } | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } -type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' type CodexReadBackMatch = | { kind: 'matched' @@ -202,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 @@ -731,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 @@ -886,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) { @@ -928,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 @@ -1057,7 +965,6 @@ export class CodexRuntimeHomeService { } private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - this.startLegacyWslAuthDrain(target) return this.getWslCodexHomePathForSelection(target) } @@ -1090,27 +997,27 @@ export class CodexRuntimeHomeService { return this.getWslSystemCodexHomePath(target) } - private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): void { + private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): Promise { if (process.platform !== 'win32') { - return + return Promise.resolve() } const distro = target.wslDistro?.trim() || getDefaultWslDistro() if (!distro) { - return + return Promise.resolve() } const guestHome = getWslHome(distro) const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null if (!parsedGuestHome) { - return + return Promise.resolve() } let legacyPanePresent: boolean try { legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) } catch (error) { console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) - return + return Promise.resolve() } - startLegacyWslRuntimeAuthDrain({ + return startLegacyWslRuntimeAuthDrain({ distro, guestHomeLinuxPath: parsedGuestHome.linuxPath, legacyPanePresent, @@ -1119,6 +1026,34 @@ export class CodexRuntimeHomeService { }) } + /** Preserve refreshed auth from retained legacy WSL panes before restart. */ + async syncActiveWslSelectionsBeforeRestart(): Promise { + if (process.platform !== 'win32') { + return + } + 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 async resolveLegacyWslAuthDestination( distro: string, runtimeAuthContents: string @@ -1178,204 +1113,6 @@ export class CodexRuntimeHomeService { : null } - syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { - if (process.platform !== 'win32') { - return null - } - - const wslTarget = this.resolveWslDefaultTarget(target) - const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, wslTarget) - ) - const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() - if (!distro) { - return null - } - - const runtimeHomePath = this.getWslRuntimeHomePath(distro) - if (!runtimeHomePath) { - return null - } - 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 { - try { - this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey) - } catch (error) { - console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error) - } - } - - 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 - }) - } - - 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')) { - 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 } - ) - } - - private dirnameLinuxPath(value: string): string { - const index = value.lastIndexOf('/') - return index > 0 ? value.slice(0, index) : '/' - } - private joinWslPath(basePath: string, ...segments: string[]): string { return parseWslUncPath(basePath) ? pathWin32.join(basePath, ...segments) @@ -1392,37 +1129,6 @@ 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, @@ -1499,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 { @@ -2209,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 @@ -2524,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 95228744bab..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 @@ -229,25 +229,6 @@ 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 selecting each account home directly', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -328,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') @@ -336,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', @@ -349,7 +329,6 @@ describe('CodexRuntimeHomeService', () => { 'runtime-stale', 1_000 ) - const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) const wslManagedHomePath = createManagedAuth( testState.userDataDir, 'wsl-account', @@ -368,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', @@ -389,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' } } }) @@ -417,143 +384,4 @@ describe('CodexRuntimeHomeService', () => { } } }) - - 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(managedHomePath) - mkdirSync(wslRuntimeHomePath, { recursive: true }) - 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(staleRuntimeAuth) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('keeps direct-home WSL token refreshes canonical across restart preservation', 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(managedHomePath) - writeFileSync(managedAuthPath, refreshedAuth, 'utf-8') - - service.syncActiveWslSelectionsBeforeRestart() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(existsSync(runtimeAuthPath)).toBe(false) - } 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 33eae33977f..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 @@ -34,61 +34,6 @@ 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('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' }) @@ -237,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()) @@ -245,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) @@ -262,11 +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 } 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 dbf0241a155..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) 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/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/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 03e06eb374f..dbe9c0c2aa4 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-session.ts