Merge remote-tracking branch 'origin/main' into tmp/native-chat-recovery-main-integration

This commit is contained in:
Brennan Benson
2026-08-26 22:28:22 -07:00
112 changed files with 4979 additions and 985 deletions
+123
View File
@@ -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",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 29m">
<title>downloads: 29m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 30m">
<title>downloads: 30m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">29m</text>
<text x="90" y="14">29m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">30m</text>
<text x="90" y="14">30m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+12
View File
@@ -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;
}
+1 -1
View File
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
react-native@0.83.9:
hash: a6502e7d63769bf1fd639cbbaab1cc0b8aebef31d22048e8f2186fb399d0c16e
hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d
path: patches/react-native@0.83.9.patch
importers:
@@ -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)))
}
@@ -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<number>()
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)
})
})
@@ -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))
})
),
[]
@@ -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<void>((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<void>(() => {}))
},
store: {
flushPendingOrThrowAsync: vi.fn(
() =>
new Promise<void>((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(() => {})
+19 -30
View File
@@ -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<CodexRuntimeHomeService, 'syncForCurrentSelection'> &
Partial<Pick<CodexRuntimeHomeService, 'syncActiveWslSelectionsBeforeRestart'>>
type ClaudeRuntimeAuthSync = Pick<ClaudeRuntimeAuthService, 'syncForCurrentSelection'>
type ShutdownStore = Pick<Store, 'flushPendingOrThrowAsync'>
@@ -27,35 +25,26 @@ export async function preserveAgentAuthBeforeRestart({
claudeRuntimeAuth,
store
}: AgentAuthRestartPreservationOptions): Promise<void> {
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<void> {
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)
})
@@ -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)
})
})
@@ -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
})
)
})
})
@@ -0,0 +1,319 @@
import { createHash } from 'node:crypto'
import { posix as pathPosix } from 'node:path'
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
import { runWslProcess } from '../wsl/wsl-runner'
import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity'
import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader'
const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json'
const MARKER_PRESENT_EXIT = 20
const SOURCE_AUTH_ABSENT_EXIT = 21
export type LegacyWslRuntimeAuthDestination = {
authContents: string
linuxHomePath: string
}
type LegacyWslRuntimeInspection = {
authContents: string
credentials: { kind: 'missing' } | { kind: 'present'; contents: string }
}
type LegacyWslRuntimeAuthDrainOptions = {
distro: string
guestHomeLinuxPath: string
legacyPanePresent: boolean
resolveDestination: (
runtimeAuthContents: string
) => LegacyWslRuntimeAuthDestination | null | Promise<LegacyWslRuntimeAuthDestination | null>
}
const drainQueueByDistro = new Map<string, Promise<void>>()
const completedDistroKeys = new Set<string>()
export function startLegacyWslRuntimeAuthDrain(
options: LegacyWslRuntimeAuthDrainOptions
): Promise<void> {
const key = options.distro.trim().toLowerCase()
if (completedDistroKeys.has(key)) {
return Promise.resolve()
}
// Coalesce launch/rate-limit callers while a drain is in flight. Queuing a
// new pass for every poll can otherwise build an unbounded promise chain
// while a legacy pane keeps the migration pending.
const inFlight = drainQueueByDistro.get(key)
if (inFlight) {
return inFlight
}
const next = drainLegacyWslRuntimeAuth(options)
.then((status) => {
if (status === 'complete') {
completedDistroKeys.add(key)
}
})
.catch((error) => {
console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error)
})
drainQueueByDistro.set(key, next)
void next.finally(() => {
if (drainQueueByDistro.get(key) === next) {
drainQueueByDistro.delete(key)
}
})
return next
}
export async function drainLegacyWslRuntimeAuth(
options: LegacyWslRuntimeAuthDrainOptions
): Promise<'complete' | 'pending'> {
const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath)
const inspection = await runWslProcess({
distro: options.distro,
loginPath: 'none',
script: INSPECT_LEGACY_AUTH_SCRIPT,
args: [paths.runtimeHome, paths.activeHome, paths.marker],
timeoutMs: 5_000,
maxOutputBytes: 2 * 1024 * 1024
})
if (inspection.code === MARKER_PRESENT_EXIT) {
return 'complete'
}
if (inspection.code === SOURCE_AUTH_ABSENT_EXIT) {
if (!options.legacyPanePresent) {
await finalizeAbsentLegacyAuth(options.distro, paths)
return 'complete'
}
return 'pending'
}
assertSuccessfulDrainStep('inspect', inspection)
const inspected = parseLegacyRuntimeInspection(inspection.stdout)
if (!inspected) {
return 'pending'
}
const destination = await options.resolveDestination(inspected.authContents)
if (!destination) {
return 'pending'
}
const freshness = compareCodexAuthFreshness(inspected.authContents, destination.authContents)
if (freshness === null) {
return 'pending'
}
const promoteAuth = codexAuthIsFresher(inspected.authContents, destination.authContents)
const result = await runWslProcess({
distro: options.distro,
loginPath: 'none',
script: APPLY_LEGACY_AUTH_SCRIPT,
args: [
paths.runtimeHome,
paths.activeHome,
paths.marker,
destination.linuxHomePath,
sha256(inspected.authContents),
sha256(destination.authContents),
promoteAuth ? '1' : '0',
options.legacyPanePresent ? '0' : '1',
inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing'
],
timeoutMs: 5_000,
maxOutputBytes: 16 * 1024
})
assertSuccessfulDrainStep('apply', result)
return options.legacyPanePresent ? 'pending' : 'complete'
}
function parseLegacyRuntimeInspection(stdout: string): LegacyWslRuntimeInspection | null {
const [authBase64, credentialsKind, credentialsBase64] = stdout.split('\n')
const authContents = decodeWslBase64Payload(authBase64 ?? '')
if (authContents === null) {
return null
}
if (credentialsKind === 'missing') {
return { authContents, credentials: { kind: 'missing' } }
}
if (credentialsKind !== 'present') {
return null
}
const credentialsContents = decodeWslBase64Payload(credentialsBase64 ?? '')
if (!credentialsContents || !isJsonObject(credentialsContents)) {
return null
}
return { authContents, credentials: { kind: 'present', contents: credentialsContents } }
}
function isJsonObject(contents: string): boolean {
try {
const value = JSON.parse(contents) as unknown
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
} catch {
return false
}
}
function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): {
activeHome: string
marker: string
runtimeHome: string
} {
const runtimeHome = pathPosix.join(guestHomeLinuxPath, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS)
const runtimeRoot = pathPosix.dirname(runtimeHome)
return {
activeHome: pathPosix.join(runtimeRoot, 'active', 'wsl', 'home'),
marker: pathPosix.join(runtimeRoot, DRAIN_MARKER_NAME),
runtimeHome
}
}
async function finalizeAbsentLegacyAuth(
distro: string,
paths: ReturnType<typeof resolveLegacyRuntimePaths>
): Promise<void> {
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()
}
}
+172 -363
View File
@@ -1,5 +1,4 @@
/* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
import {
appendFileSync,
copyFileSync,
@@ -18,7 +17,6 @@ import {
unlinkSync
} from 'node:fs'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import { execFileSync } from 'node:child_process'
import {
dirname,
extname,
@@ -33,7 +31,6 @@ import { app } from 'electron'
import type { CodexManagedAccount } from '../../shared/managed-account-types'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type { Store } from '../persistence'
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
import {
recoverInterruptedGuardedFileOperation,
removeFileAtomicallyIfUnchanged,
@@ -55,16 +52,13 @@ import {
resolveWslCodexSessionSourceHome
} from '../codex/codex-session-source-home'
import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge'
import {
prepareSystemConfigForFreshRuntimeMirror,
syncSystemConfigIntoManagedCodexHome
} from '../codex/codex-config-mirror'
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths'
import {
getCodexSelectionLaneKey,
getWslSelectionKey,
getSelectedCodexAccountIdForTarget,
normalizeCodexRuntimeSelection,
setSelectedCodexAccountIdForTarget,
type CodexAccountSelectionTarget
} from './runtime-selection'
import { getDefaultWslDistro, getWslHome } from '../wsl'
@@ -91,10 +85,16 @@ import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace'
import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility'
import {
getCodexPaneAccount,
hasRecordedLegacyWslCodexPane,
hasRecordedLegacySharedCodexPane,
type CodexPaneHomeRoute
} from '../codex/codex-pane-account-registry'
import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env'
import {
startLegacyWslRuntimeAuthDrain,
type LegacyWslRuntimeAuthDestination
} from './legacy-wsl-runtime-auth-drain'
import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader'
type CodexSystemDefaultSnapshot = {
authJson: string | null
@@ -130,7 +130,6 @@ type CodexRuntimeLogoutMarkerStatus =
| { kind: 'applies' }
| { kind: 'system-default-changed'; systemDefaultAuthJson: string | null }
type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected'
type CodexReadBackMatch =
| {
kind: 'matched'
@@ -195,11 +194,6 @@ export class CodexRuntimeHomeService {
private lastSyncedAccountId: string | null = null
// Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt).
private lastWrittenAuthJson: string | null = null
// Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage.
private readonly lastWrittenWslAuthJsonByDistro = new Map<string, string | null>()
private readonly lastSyncedWslAccountIdByDistro = new Map<string, string | null>()
private readonly wslRuntimeHomePathByDistro = new Map<string, string>()
private skipNextReadBackForAccountId: string | null = null
// Why: a managed host account refreshes auth in its own home. Remember that
// provenance so a later deselect never adopts stale shared bytes.
private lastHostAccountUsedSelfContainedHome = false
@@ -244,11 +238,11 @@ export class CodexRuntimeHomeService {
): string | null {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget)
this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath)
const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath)
return runtimeHomePath
const homePath = this.getWslCodexHomePathForSelection(wslTarget)
this.startLegacyWslAuthDrain(wslTarget)
this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, homePath)
this.startWslSessionBridgeForLaunch(wslTarget, homePath)
return homePath
}
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
if (selfContainedAccount) {
@@ -724,26 +718,6 @@ export class CodexRuntimeHomeService {
syncLegacySharedCodexConfigForRetainedPanes()
}
syncActiveWslSelectionsBeforeRestart(): void {
if (process.platform !== 'win32') {
return
}
const settings = this.store.getSettings()
for (const [selectedDistroKey, accountId] of Object.entries(
normalizeCodexRuntimeSelection(settings).wsl
)) {
if (!accountId) {
continue
}
const account = this.getActiveAccount(settings.codexManagedAccounts, accountId)
if (!account || account.managedHomeRuntime !== 'wsl') {
continue
}
this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey)
}
}
private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null {
if (process.platform !== 'win32') {
return null
@@ -790,10 +764,9 @@ export class CodexRuntimeHomeService {
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget)
return {
kind: 'ready',
codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget)
}
}
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
@@ -833,7 +806,7 @@ export class CodexRuntimeHomeService {
launchEnv?: NodeJS.ProcessEnv
): void {
if (target?.runtime === 'wsl') {
this.syncWslRuntimeForCurrentSelection(target)
this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target))
return
}
@@ -880,7 +853,6 @@ export class CodexRuntimeHomeService {
// distro-local runtime home, so the host mirror only drops its baseline.
this.lastSyncedAccountId = null
this.lastWrittenAuthJson = null
this.skipNextReadBackForAccountId = null
return
}
if (normalizeCodexRuntimeSelection(settings).host) {
@@ -922,71 +894,13 @@ export class CodexRuntimeHomeService {
}
}
// Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens.
// Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline.
clearLastWrittenAuthJson(
accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host
): void {
if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) {
this.lastWrittenAuthJson = null
}
this.skipNextReadBackForAccountId = accountId
}
private readBackRefreshedTokensFromPath(
runtimeAuthPath: string,
options: {
updateLastWrittenAuthJson: boolean
lastWrittenAuthJson?: string | null
setLastWrittenAuthJson?: (contents: string) => void
expectedAccountId?: string
}
): CodexReadBackResult {
try {
if (!existsSync(runtimeAuthPath)) {
return 'unchanged'
}
const lastWrittenAuthJson =
options.lastWrittenAuthJson === undefined
? this.lastWrittenAuthJson
: options.lastWrittenAuthJson
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) {
return 'unchanged'
}
const match = this.findManagedAccountForRuntimeAuth(
runtimeContents,
options.expectedAccountId
)
if (match.kind !== 'matched') {
if (match.kind === 'ambiguous') {
console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back')
}
return 'rejected'
}
// Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage.
if (
lastWrittenAuthJson === null &&
!this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents)
) {
return 'rejected'
}
writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 })
if (options.updateLastWrittenAuthJson) {
if (options.setLastWrittenAuthJson) {
options.setLastWrittenAuthJson(runtimeContents)
} else {
this.lastWrittenAuthJson = runtimeContents
}
}
return 'persisted'
} catch (error) {
// Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle.
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
return 'rejected'
}
}
// Why: which ~/.codex bytes the mirror was seeded from, and whether the system
@@ -1051,223 +965,152 @@ export class CodexRuntimeHomeService {
}
private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null {
const distro = target.wslDistro?.trim()
if (distro) {
const settings = this.store.getSettings()
const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target)
if (selectedAccountId === null) {
// Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy.
return this.getWslSystemCodexHomePath(target)
}
const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
if (
cachedRuntimeHomePath &&
this.lastSyncedWslAccountIdByDistro.has(distro) &&
this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId
) {
// Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe.
return cachedRuntimeHomePath
}
}
return this.syncWslRuntimeForCurrentSelection(target)
return this.getWslCodexHomePathForSelection(target)
}
private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null {
if (process.platform !== 'win32') {
return null
}
const wslTarget = this.resolveWslDefaultTarget(target)
private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null {
const settings = this.store.getSettings()
const activeAccount = this.getActiveAccount(
const account = this.getActiveAccount(
settings.codexManagedAccounts,
getSelectedCodexAccountIdForTarget(settings, wslTarget)
getSelectedCodexAccountIdForTarget(settings, target)
)
const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro()
if (account) {
const managedHome = parseWslUncPath(account.managedHomePath)
const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim()
// Persisted selections can outlive an account's runtime metadata. Never
// hand a host home (or another distro's UNC home) to a WSL launch.
const accountDistro = account.wslDistro?.trim()
const distroMatches =
(!accountDistro ||
!targetDistro ||
accountDistro.toLowerCase() === targetDistro.toLowerCase()) &&
(!managedHome ||
!targetDistro ||
managedHome.distro.toLowerCase() === targetDistro.toLowerCase())
// Older persisted fixtures may not carry a UNC spelling, but the
// explicit WSL runtime marker still proves the lane; reject unmarked
// host paths while retaining those legacy WSL records.
if (distroMatches && (account.managedHomeRuntime === 'wsl' || managedHome)) {
return account.managedHomePath
}
}
return this.getWslSystemCodexHomePath(target)
}
private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): Promise<void> {
if (process.platform !== 'win32') {
return Promise.resolve()
}
const distro = target.wslDistro?.trim() || getDefaultWslDistro()
if (!distro) {
return null
return Promise.resolve()
}
const runtimeHomePath = this.getWslRuntimeHomePath(distro)
if (!runtimeHomePath) {
return null
const guestHome = getWslHome(distro)
const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null
if (!parsedGuestHome) {
return Promise.resolve()
}
this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath)
mkdirSync(runtimeHomePath, { recursive: true })
this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath)
this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro)
const runtimeAuthPath = join(runtimeHomePath, 'auth.json')
const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null
if (previousWslAccountId) {
if (this.skipNextReadBackForAccountId === previousWslAccountId) {
this.skipNextReadBackForAccountId = null
} else {
const previousWslAccount = this.getActiveAccount(
settings.codexManagedAccounts,
previousWslAccountId
)
if (previousWslAccount) {
this.readBackRefreshedTokensFromPath(runtimeAuthPath, {
updateLastWrittenAuthJson: true,
lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null,
setLastWrittenAuthJson: (contents) => {
this.lastWrittenWslAuthJsonByDistro.set(distro, contents)
},
expectedAccountId: previousWslAccount.id
})
}
}
}
const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null
if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) {
const activeAuth = readFileSync(activeAuthPath, 'utf-8')
this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth)
this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth)
this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id)
return runtimeHomePath
}
if (activeAccount && activeAuthPath) {
console.warn(
'[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default'
)
this.store.updateSettings({
activeCodexManagedAccountId: settings.activeCodexManagedAccountId,
activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget(
normalizeCodexRuntimeSelection(settings),
null,
wslTarget
)
})
}
const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro })
if (systemAuthPath && existsSync(systemAuthPath)) {
const systemAuth = readFileSync(systemAuthPath, 'utf-8')
const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null
const runtimeAuth = existsSync(runtimeAuthPath)
? readFileSync(runtimeAuthPath, 'utf-8')
: null
if (
runtimeAuth !== null &&
runtimeAuth !== systemAuth &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) &&
((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) ||
(mirroredSystemDefaultAuth === null &&
this.runtimeAuthIsFresher(runtimeAuth, systemAuth)))
) {
// Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex.
this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth)
this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth)
this.lastSyncedWslAccountIdByDistro.set(distro, null)
return runtimeHomePath
}
this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth)
this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth)
this.lastSyncedWslAccountIdByDistro.set(distro, null)
return runtimeHomePath
}
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenWslAuthJsonByDistro.set(distro, null)
this.lastSyncedWslAccountIdByDistro.set(distro, null)
return runtimeHomePath
}
private getWslRuntimeHomePath(distro: string): string | null {
const home = getWslHome(distro)
return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null
}
private safeReadBackActiveWslAccountBeforeRestart(
account: CodexManagedAccount,
selectedDistroKey: string
): void {
let legacyPanePresent: boolean
try {
this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey)
legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target))
} catch (error) {
console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error)
console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error)
return Promise.resolve()
}
}
private readBackActiveWslAccountBeforeRestart(
account: CodexManagedAccount,
selectedDistroKey: string
): void {
const distro =
selectedDistroKey === getWslSelectionKey(null)
? account.wslDistro?.trim()
: selectedDistroKey.trim() || account.wslDistro?.trim()
if (!distro) {
return
}
const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
if (!runtimeHomePath) {
return
}
this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), {
updateLastWrittenAuthJson: true,
lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null,
setLastWrittenAuthJson: (contents) => {
this.lastWrittenWslAuthJsonByDistro.set(distro, contents)
},
expectedAccountId: account.id
return startLegacyWslRuntimeAuthDrain({
distro,
guestHomeLinuxPath: parsedGuestHome.linuxPath,
legacyPanePresent,
resolveDestination: (runtimeAuthContents) =>
this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents)
})
}
private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void {
try {
this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath)
} catch (error) {
console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error)
}
}
private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void {
const runtimeWsl = parseWslUncPath(runtimeHomePath)
if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) {
/** Preserve refreshed auth from retained legacy WSL panes before restart. */
async syncActiveWslSelectionsBeforeRestart(): Promise<void> {
if (process.platform !== 'win32') {
return
}
const activeLinuxPath = runtimeWsl.linuxPath.replace(
/\/codex-runtime-home\/home$/,
'/codex-runtime-home/active/wsl/home'
)
const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}`
const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath)
// Why: login-shell cleanup turns `exit 0` into status 1, so fall through.
execFileSync(
'wsl.exe',
[
'-d',
distro,
'--exec',
'bash',
'-lc',
[
'set -e',
`if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`,
`elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`,
'else',
`mkdir -p ${quotePosixShell(activeLinuxParentPath)}`,
`rm -rf -- ${quotePosixShell(nextLinuxPath)}`,
`ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`,
`mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`,
'fi'
].join('\n')
],
// wsl.exe is console-subsystem: without this a GUI-launched Orca flashes
// a conhost and steals foreground for up to the timeout (#10488).
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true }
)
const settings = this.store.getSettings()
const drains: Promise<void>[] = []
for (const [selectedDistroKey, accountId] of Object.entries(
normalizeCodexRuntimeSelection(settings).wsl
)) {
if (!accountId) {
continue
}
const account = this.getActiveAccount(settings.codexManagedAccounts, accountId)
if (!account || account.managedHomeRuntime !== 'wsl') {
continue
}
const distro =
selectedDistroKey === getWslSelectionKey(null)
? account.wslDistro?.trim() || null
: selectedDistroKey.trim() || null
if (distro) {
drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro }))
}
}
await Promise.all(drains)
}
private dirnameLinuxPath(value: string): string {
const index = value.lastIndexOf('/')
return index > 0 ? value.slice(0, index) : '/'
private async resolveLegacyWslAuthDestination(
distro: string,
runtimeAuthContents: string
): Promise<LegacyWslRuntimeAuthDestination | null> {
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<string, WslCodexAuthRead>(
accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }])
)
const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, {
accounts,
authReads
})
if (match.kind === 'ambiguous') {
return null
}
if (match.kind === 'matched') {
const parsedHome = parseWslUncPath(match.account.managedHomePath)
if (!parsedHome || parsedHome.distro.toLowerCase() !== distro.toLowerCase()) {
return null
}
return {
authContents: match.managedAuthContents,
linuxHomePath: parsedHome.linuxPath
}
}
if (!systemHome || !parsedSystemHome) {
return null
}
const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' }
if (systemAuth.kind !== 'present') {
return null
}
return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents)
? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath }
: null
}
private joinWslPath(basePath: string, ...segments: string[]): string {
@@ -1286,40 +1129,13 @@ export class CodexRuntimeHomeService {
return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target
}
private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null {
const home = this.getWslSystemCodexHomePath(target)
return home ? this.joinWslPath(home, 'auth.json') : null
}
private seedWslRuntimeHome(
runtimeHomePath: string,
activeAccount: CodexManagedAccount | null,
distro: string
): void {
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
if (existsSync(runtimeConfigPath)) {
return
}
const candidateHomes = [
activeAccount?.managedHomePath,
this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
].filter((value): value is string => Boolean(value))
for (const homePath of candidateHomes) {
const configPath = join(homePath, 'config.toml')
if (existsSync(configPath)) {
writeFileAtomically(
runtimeConfigPath,
prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath)
)
return
}
}
}
private findManagedAccountForRuntimeAuth(
runtimeAuthContents: string,
expectedAccountId?: string
expectedAccountId?: string,
options?: {
accounts: readonly CodexManagedAccount[]
authReads: ReadonlyMap<string, WslCodexAuthRead>
}
): CodexReadBackMatch {
const matches: {
account: CodexManagedAccount
@@ -1327,18 +1143,17 @@ export class CodexRuntimeHomeService {
managedAuthContents: string
}[] = []
let unreadableHomeCouldOwnRuntimeAuth = false
for (const account of this.store.getSettings().codexManagedAccounts) {
for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) {
if (expectedAccountId && account.id !== expectedAccountId) {
continue
}
const managedAuthPath = join(account.managedHomePath, 'auth.json')
if (!existsSync(managedAuthPath)) {
let managedAuthContents: string
const suppliedRead = options?.authReads.get(account.id)
if (suppliedRead?.kind === 'missing') {
continue
}
let managedAuthContents: string
try {
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
} catch {
if (suppliedRead?.kind === 'unreadable') {
// Why: an unreadable home can never be compared, but letting the read
// throw abandons the scan for every other account — dropping a refresh
// the runtime home holds for one of them. Only its record can rule it
@@ -1351,6 +1166,24 @@ export class CodexRuntimeHomeService {
}
continue
}
if (suppliedRead?.kind === 'present') {
managedAuthContents = suppliedRead.contents
} else {
if (!existsSync(managedAuthPath)) {
continue
}
try {
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
} catch {
if (
!expectedAccountId &&
codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account)
) {
unreadableHomeCouldOwnRuntimeAuth = true
}
continue
}
}
if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) {
matches.push({ account, managedAuthPath, managedAuthContents })
}
@@ -1372,10 +1205,6 @@ export class CodexRuntimeHomeService {
return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents)
}
private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean {
return codexAuthIsFresher(runtimeAuthContents, managedAuthContents)
}
private safeMigrateLegacySharedAuth(): void {
const settings = this.store.getSettings()
try {
@@ -2082,15 +1911,6 @@ export class CodexRuntimeHomeService {
return true
}
private writeRuntimeAuthAtPath(authPath: string, contents: string): void {
if (this.fileContentsEqual(authPath, contents)) {
this.ensureOwnerOnlyMode(authPath)
return
}
mkdirSync(dirname(authPath), { recursive: true })
writeFileAtomically(authPath, contents, { mode: 0o600 })
}
/**
* `true`/`false` only when the bytes were actually read; `null` when the file
* could not be read at all. The old `catch { return false }` reported "these
@@ -2397,14 +2217,3 @@ export class CodexRuntimeHomeService {
rmSync(this.getSystemDefaultSnapshotPath(), { force: true })
}
}
// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2).
export function prepareWslRuntimeSeedConfig(
configContents: string,
sourceHomePath: string
): string {
return prepareSystemConfigForFreshRuntimeMirror(
configContents,
parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath
)
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createSettings } from './runtime-home-settings-test-fixtures'
import {
@@ -87,18 +87,16 @@ describe('CodexRuntimeHomeService', () => {
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n')
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(
'{"account":"wsl"}\n'
wslManagedHomePath
)
expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false)
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getRuntimeCodexHomePath()
})
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({
kind: 'ready',
codexHomePath: wslRuntimeHomePath
codexHomePath: wslManagedHomePath
})
} finally {
if (originalPlatform) {
@@ -107,7 +105,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('clears a selected WSL managed account when auth.json is missing', async () => {
it('keeps a selected WSL managed home when auth.json is temporarily missing', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -161,13 +159,11 @@ describe('CodexRuntimeHomeService', () => {
)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
managedHomePath
)
expect(store.updateSettings).toHaveBeenCalledWith({
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } }
})
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth)
expect(store.updateSettings).not.toHaveBeenCalled()
expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false)
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -175,7 +171,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => {
it('launches WSL system default against its existing config without a runtime seed', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -214,21 +210,18 @@ describe('CodexRuntimeHomeService', () => {
)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
systemCodexHomePath
)
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
expect(runtimeConfig).toContain(
`model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'`
)
expect(runtimeConfig).toContain('[projects."/home/alice/repo"]')
expect(runtimeConfig).not.toContain('[hooks.state.')
expect(existsSync(runtimeConfigPath)).toBe(false)
const systemConfigPath = join(systemCodexHomePath, 'config.toml')
const systemConfig = readFileSync(systemConfigPath, 'utf-8')
expect(systemConfig).toContain('model_instructions_file = "instructions.md"')
expect(systemConfig).toContain('[hooks.state.')
// Why: WSL runtime configs are seeded once; Codex writes trust into them
// afterwards, so a relaunch must not clobber the seeded file.
writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8')
writeFileSync(systemConfigPath, `${systemConfig}\n[projects."/tmp/x"]\n`, 'utf-8')
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]')
expect(readFileSync(systemConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]')
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -236,26 +229,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => {
const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service')
// Why: real UNC sources cannot back live fs operations in tests, so pin
// the UNC -> Linux-side anchor translation on the extracted seed function.
expect(
prepareWslRuntimeSeedConfig(
'model_instructions_file = "instructions.md"\n',
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
)
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
expect(
prepareWslRuntimeSeedConfig(
'model_instructions_file = "instructions.md"\n',
'\\\\wsl$\\Ubuntu\\home\\alice\\.codex'
)
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
})
it('switches WSL accounts by rewriting one stable WSL runtime home', async () => {
it('switches WSL accounts by selecting each account home directly', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -317,16 +291,17 @@ describe('CodexRuntimeHomeService', () => {
'home'
)
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth)
expect(service.prepareForCodexLaunch(target)).toBe(firstManagedHomePath)
expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false)
store.updateSettings({
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } }
})
service.syncForCurrentSelection(target)
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth)
expect(service.prepareForCodexLaunch(target)).toBe(secondManagedHomePath)
expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth)
expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -334,7 +309,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('does not use host auth baseline to accept stale WSL runtime auth', async () => {
it('ignores stale retired runtime auth when launching a managed WSL account', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -342,7 +317,6 @@ describe('CodexRuntimeHomeService', () => {
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token')
const wslManagedAuth = createCodexAuthJson(
'wsl@example.com',
'acct-wsl',
@@ -355,7 +329,6 @@ describe('CodexRuntimeHomeService', () => {
'runtime-stale',
1_000
)
const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth)
const wslManagedHomePath = createManagedAuth(
testState.userDataDir,
'wsl-account',
@@ -374,17 +347,6 @@ describe('CodexRuntimeHomeService', () => {
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'host-account',
email: 'host@example.com',
managedHomePath: hostManagedHomePath,
providerAccountId: 'acct-host',
workspaceLabel: null,
workspaceAccountId: 'acct-host',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'wsl-account',
email: 'wsl@example.com',
@@ -395,14 +357,13 @@ describe('CodexRuntimeHomeService', () => {
providerAccountId: 'acct-wsl',
workspaceLabel: null,
workspaceAccountId: 'acct-wsl',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'host-account',
activeCodexManagedAccountIdsByRuntime: {
host: 'host-account',
host: null,
wsl: { Ubuntu: 'wsl-account' }
}
})
@@ -413,148 +374,10 @@ describe('CodexRuntimeHomeService', () => {
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
wslManagedHomePath
)
expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000)
const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500)
const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000)
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth)
const managedAuthPath = join(managedHomePath, 'auth.json')
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json')
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'wsl@example.com',
managedHomePath,
managedHomeRuntime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home',
providerAccountId: 'acct-wsl',
workspaceLabel: null,
workspaceAccountId: 'acct-wsl',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'account-1' }
}
})
)
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8')
writeFileSync(managedAuthPath, reauthedAuth, 'utf-8')
service.clearLastWrittenAuthJson('account-1')
service.syncForCurrentSelection(target)
expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('reads active WSL token refreshes back before restart using the selected distro', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed', 1_000)
const refreshedAuth = createCodexAuthJson(
'wsl@example.com',
'acct-wsl',
'runtime-refreshed',
2_000
)
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
const managedAuthPath = join(managedHomePath, 'auth.json')
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'wsl@example.com',
managedHomePath,
managedHomeRuntime: 'wsl',
wslDistro: null,
wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home',
providerAccountId: 'acct-wsl',
workspaceLabel: null,
workspaceAccountId: 'acct-wsl',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'account-1' }
}
})
)
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json')
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8')
service.syncActiveWslSelectionsBeforeRestart()
expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type * as WslPaths from '../../shared/wsl-paths'
import { createSettings } from './runtime-home-settings-test-fixtures'
@@ -34,62 +34,7 @@ describe('CodexRuntimeHomeService', () => {
teardownRuntimeHomeTest()
})
it('builds a valid WSL legacy active-home migration shell command', async () => {
const execFileSyncMock = vi.fn()
vi.doMock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(
createStore(createSettings()) as never
) as unknown as {
migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void
}
service.migrateLegacyWslActiveHomePointer(
'Ubuntu',
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
)
expect(execFileSyncMock).toHaveBeenCalledTimes(1)
const firstCall = execFileSyncMock.mock.calls[0]
expect(firstCall).toBeDefined()
const [command, args] = firstCall as [string, string[]]
expect(command).toBe('wsl.exe')
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc'])
expect(args).toHaveLength(6)
const shellCommand = args[5]
expect(shellCommand).toContain(
"if [ ! -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :"
)
expect(shellCommand).toContain(
"elif [ -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :"
)
expect(shellCommand).toContain(
"mkdir -p '/home/alice/.local/share/orca/codex-runtime-home/active/wsl'"
)
expect(shellCommand).toContain(
"ln -s -- '/home/alice/.local/share/orca/codex-runtime-home/home' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-"
)
expect(shellCommand).toContain(
"mv -Tf -- '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-"
)
expect(shellCommand).toContain(
"' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home'"
)
expect(shellCommand).not.toContain('[! -L')
expect(shellCommand).not.toContain('mv -Tf--')
expect(shellCommand).not.toContain('$1')
expect(shellCommand).not.toContain('$2')
expect(shellCommand).not.toContain('$3')
expect(shellCommand).not.toContain('exit 0')
} finally {
vi.doUnmock('node:child_process')
}
})
it('starts WSL session bridging after materializing the WSL launch home', async () => {
it('skips WSL session bridging when system default already uses its direct home', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
@@ -124,17 +69,13 @@ describe('CodexRuntimeHomeService', () => {
)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
wslSystemHomePath
)
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1)
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
distro: 'Ubuntu',
systemCodexHomePath: wslSystemHomePath,
managedCodexHomePath: wslRuntimeHomePath
})
const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md')
expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n')
expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false)
expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled()
expect(readFileSync(join(wslSystemHomePath, 'AGENTS.md'), 'utf-8')).toBe(
'# WSL instructions\n'
)
expect(existsSync(join(wslRuntimeHomePath, 'AGENTS.md'))).toBe(false)
} finally {
vi.doUnmock('../codex/wsl-codex-session-bridge')
vi.doUnmock('../wsl')
@@ -144,7 +85,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('promotes WSL in-Codex setting changes on the next Codex launch', async () => {
it('keeps WSL in-Codex setting changes in the direct system home', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
@@ -177,32 +118,20 @@ describe('CodexRuntimeHomeService', () => {
'home'
)
// First launch seeds the runtime config and records the per-distro baseline.
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
join(wslHome, '.codex')
)
const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json')
expect(existsSync(baselinePath)).toBe(true)
expect(existsSync(baselinePath)).toBe(false)
// A direct WSL Codex edit wins and is mirrored into Orca's runtime before
// the baseline advances, so later in-Orca changes remain promotable.
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8')
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n')
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""')
// Codex now persists a /model change inside Orca's reconciled runtime.
writeFileSync(
runtimeConfigPath,
readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'),
'utf-8'
)
expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "outside-edit"\n')
writeFileSync(wslSystemConfigPath, 'model = "o4"\n', 'utf-8')
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n')
// Baseline advances so the promoted value is not re-promoted forever.
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""')
expect(existsSync(baselinePath)).toBe(false)
} finally {
vi.doUnmock('../codex/wsl-codex-session-bridge')
vi.doUnmock('../wsl')
@@ -236,22 +165,13 @@ describe('CodexRuntimeHomeService', () => {
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1)
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
distro: 'Ubuntu',
systemCodexHomePath: '/home/me/.config/codex',
managedCodexHomePath: wslRuntimeHomePath
managedCodexHomePath: join(wslHome, '.codex')
})
} finally {
vi.doUnmock('../codex/wsl-codex-session-bridge')
@@ -262,7 +182,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('starts WSL session bridging for the distro used by the materialized runtime home', async () => {
it('starts WSL session bridging for the selected direct account home', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
@@ -270,14 +190,6 @@ describe('CodexRuntimeHomeService', () => {
startWslCodexSessionBridgeInBackground
}))
const wslHome = join(testState.userDataDir, 'debian-wsl-home')
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => null,
getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null)
@@ -287,10 +199,10 @@ describe('CodexRuntimeHomeService', () => {
return {
...actual,
parseWslUncPath: (candidate: string) =>
candidate === wslRuntimeHomePath
candidate.includes('codex-accounts/debian-account/home')
? {
distro: 'Debian',
linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home'
linuxPath: '/home/alice/.local/share/orca/codex-accounts/debian-account/home'
}
: null
}
@@ -328,12 +240,12 @@ describe('CodexRuntimeHomeService', () => {
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe(
wslRuntimeHomePath
managedHomePath
)
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
distro: 'Debian',
systemCodexHomePath: join(wslHome, '.codex'),
managedCodexHomePath: wslRuntimeHomePath
managedCodexHomePath: managedHomePath
})
} finally {
vi.doUnmock('../codex/wsl-codex-session-bridge')
@@ -53,15 +53,6 @@ describe('CodexRuntimeHomeService', () => {
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const syncWslRuntime = vi.spyOn(
service as unknown as {
syncWslRuntimeForCurrentSelection: (target: {
runtime: 'wsl'
wslDistro?: string | null
}) => string | null
},
'syncWslRuntimeForCurrentSelection'
)
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
const expectedHome = join(wslHome, '.codex')
@@ -73,7 +64,6 @@ describe('CodexRuntimeHomeService', () => {
kind: 'ready',
codexHomePath: expectedHome
})
expect(syncWslRuntime).not.toHaveBeenCalled()
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -93,15 +83,6 @@ describe('CodexRuntimeHomeService', () => {
const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token')
const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth)
const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth)
const runtimeAuthPath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home',
'auth.json'
)
const store = createStore(
createSettings({
codexManagedAccounts: [
@@ -147,9 +128,9 @@ describe('CodexRuntimeHomeService', () => {
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({
kind: 'ready',
codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
codexHomePath: ubuntuHomePath
})
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth)
expect(readFileSync(join(ubuntuHomePath, 'auth.json'), 'utf-8')).toBe(ubuntuAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -228,7 +209,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('reads WSL system-default token refreshes back to WSL system auth', async () => {
it('keeps WSL system-default token refreshes in its direct home', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -257,21 +238,11 @@ describe('CodexRuntimeHomeService', () => {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath)
writeFileSync(join(systemCodexHomePath, 'auth.json'), refreshedAuth, 'utf-8')
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8')
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath)
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -279,7 +250,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('preserves WSL system-default token refreshes after app restart', async () => {
it('does not overwrite direct WSL system auth from the retired runtime on restart', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -319,8 +290,8 @@ describe('CodexRuntimeHomeService', () => {
const service = new CodexRuntimeHomeService(store as never)
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath)
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
} finally {
if (originalPlatform) {
@@ -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<WslCodexAuthRead[]> {
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
`
@@ -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(
@@ -202,7 +202,7 @@ describe('resolveCodexPaneLaunchAccount', () => {
).toEqual({
selectionKey: 'wsl:Ubuntu',
accountId: 'wsl-account',
homeRoute: 'wsl-home'
homeRoute: 'account-home'
})
})
+12 -5
View File
@@ -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. */
@@ -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',
@@ -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
@@ -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()
})
})
@@ -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[]
@@ -10,6 +10,10 @@ export type ConfirmForegroundProcessRequest = Omit<GetForegroundProcessRequest,
type: 'confirmForegroundProcess'
}
export type ConfirmShellForegroundRequest = Omit<GetForegroundProcessRequest, 'type'> & {
type: 'confirmShellForeground'
}
export type InspectProcessRequest = Omit<GetForegroundProcessRequest, 'type'> & {
type: 'inspectProcess'
}
@@ -29,6 +29,7 @@ export function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle
write: ReturnType<typeof vi.fn<(data: string) => void>>
pause: ReturnType<typeof vi.fn<() => void>>
resume: ReturnType<typeof vi.fn<() => void>>
confirmShellForeground: ReturnType<typeof vi.fn<() => Promise<boolean>>>
_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>(),
@@ -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')
@@ -64,7 +64,8 @@ export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl {
...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}),
...(snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
: {})
: {}),
...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {})
}
}
@@ -72,6 +72,17 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot
}
}
async confirmShellForeground(id: string): Promise<boolean> {
try {
const result = await this.client.request<{ confirmed: boolean }>('confirmShellForeground', {
sessionId: id
})
return result.confirmed === true
} catch {
return false
}
}
async serialize(ids: string[]): Promise<string> {
const sessions: Record<string, { initialCwd?: string }> = {}
for (const id of ids) {
+4
View File
@@ -185,6 +185,10 @@ export class DaemonPtyRouter implements IPtyProvider {
return this.adapterFor(id).confirmForegroundProcess(id)
}
async confirmShellForeground(id: string): Promise<boolean> {
return (await this.adapterFor(id).confirmShellForeground?.(id)) ?? false
}
async serialize(ids: string[]): Promise<string> {
return this.current.serialize(ids)
}
@@ -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.
+6 -2
View File
@@ -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<unknown> {
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', {
@@ -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',
@@ -194,6 +194,9 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
async confirmForegroundProcess(id: string): Promise<string | null> {
return this.providerFor(id).confirmForegroundProcess?.(id) ?? null
}
async confirmShellForeground(id: string): Promise<boolean> {
return (await this.providerFor(id).confirmShellForeground?.(id)) ?? false
}
async serialize(ids: string[]): Promise<string> {
return this.fallback.serialize(ids)
@@ -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<string | null>
confirmShellForeground(): Promise<boolean>
}
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 })
}
}
@@ -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<boolean> {
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
}
@@ -71,6 +71,7 @@ export function createDaemonPtySubprocessHandle(args: {
: {}),
getForegroundProcess: foreground.getForegroundProcess,
confirmForegroundProcess: foreground.confirmForegroundProcess,
confirmShellForeground: foreground.confirmShellForeground,
write: (data) => {
if (dead) {
return
@@ -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 }
}
+37 -2
View File
@@ -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<void> {
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 {
@@ -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<boolean>((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')
})
})
@@ -9,6 +9,8 @@ export type SubprocessHandle = {
getForegroundProcess(): string | null
/** Await process-table evidence captured after this confirmation request. */
confirmForegroundProcess?(): Promise<string | null>
/** Proves a fresh post-boundary PTY process tree contains only the shell. */
confirmShellForeground?(): Promise<boolean>
/** 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
+50
View File
@@ -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<string, unknown>) => 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[] = []
+41 -27
View File
@@ -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<boolean> {
return this.recoveryBarrier.confirmOwnerSettled()
}
async settleShellOwnershipConfirmation(): Promise<void> {
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()
@@ -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')
@@ -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
@@ -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)
)
}
@@ -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 } : {})
}
}
@@ -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<boolean> } = {}
): 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<boolean>(() => {}))
})
const spawnSubprocess = vi
.fn<() => Promise<SubprocessHandle>>()
.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<SubprocessHandle>>()
@@ -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()
+26
View File
@@ -233,6 +233,17 @@ export class TerminalHost {
return session.confirmForegroundProcess()
}
async confirmShellForeground(sessionId: string): Promise<boolean> {
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<TerminalSnapshot | null> {
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)
@@ -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<boolean>
deadlineMs: () => number
isDisposed: () => boolean
isAlive: () => boolean
}
) {}
get inFlight(): boolean {
return this.inFlightState
}
waitSettled(): Promise<void> {
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<boolean>
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()
}
}
@@ -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()
})
})
@@ -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))
}
@@ -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<boolean>
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<boolean>((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)
})
})
@@ -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<boolean>
/** 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<boolean>
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<typeof setTimeout> | 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<boolean> {
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<void> {
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<void> {
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<boolean>
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()
}
}
}
@@ -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<boolean>, 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)
})
})
+3
View File
@@ -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
}
+3
View File
@@ -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
+1
View File
@@ -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
+7 -7
View File
@@ -35,14 +35,14 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
: undefined
if (typeof ctx.result.snapshot === 'string' && ctx.result.snapshot.length > 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' &&
+2
View File
@@ -12,6 +12,7 @@ import {
attachPtyFromRuntimeController,
clearBufferFromRuntimeController,
confirmForegroundProcessFromRuntimeController,
confirmShellForegroundFromRuntimeController,
getCwdFromRuntimeController,
getForegroundProcessFromRuntimeController,
getRendererSerializerGenerationFromRuntimeController,
@@ -58,6 +59,7 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId),
inspectProcess: (ptyId) => inspectProcessFromRuntimeController(ptyId),
confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId),
confirmShellForeground: (ptyId) => confirmShellForegroundFromRuntimeController(ptyId),
getCwd: (ptyId) => getCwdFromRuntimeController(ptyId),
hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId),
clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId),
+8
View File
@@ -142,6 +142,14 @@ export async function confirmForegroundProcessFromRuntimeController(ptyId: strin
}
}
export async function confirmShellForegroundFromRuntimeController(ptyId: string) {
try {
return (await getProviderForPty(ptyId).confirmShellForeground?.(ptyId)) ?? false
} catch {
return false
}
}
export async function getCwdFromRuntimeController(ptyId: string) {
try {
const cwd = await getProviderForPty(ptyId).getCwd(ptyId)
@@ -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)
})
})
@@ -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(
[
@@ -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<number>
| null
| Promise<ReadonlySet<number> | null>
}
function collectDescendants<Row extends { pid: number; ppid: number }>(
rows: Row[],
rootPid: number
@@ -49,6 +57,61 @@ function collectDescendants<Row extends { pid: number; ppid: number }>(
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<boolean> {
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
+19 -1
View File
@@ -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<boolean> {
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<string> {
return '{}'
}
@@ -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<string | null>
/** Strong process evidence captured after the caller's command boundary. */
confirmForegroundProcess?: (id: string) => Promise<string | null>
/** Fresh execution-host proof that the spawned shell owns the PTY foreground. */
confirmShellForeground?: (id: string) => Promise<boolean>
serialize(ids: string[]): Promise<string>
revive(state: string): Promise<void>
// Why: deadlineMs bounds the underlying RPC exactly like shutdown's deadlineMs.
+3
View File
@@ -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.
@@ -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(
+1 -3
View File
@@ -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',
@@ -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<string, unknown>
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<string, unknown>
return {
...actual,
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
getBranchConflictKind: vi.fn().mockResolvedValue(null)
}
})
vi.mock('../git/git-username', async () => {
const actual = await vi.importActual<typeof GitUsernameModule>('../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<string, RuntimeMobileSessionTabsSnapshot>
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)
})
})
@@ -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<void>
ownership: { settle: () => Promise<void>; owner: undefined }
}
const runtimePrivate = runtime as unknown as {
headlessTerminals: Map<string, HeadlessStateForTest>
@@ -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<string, RuntimeMobileSessionTabsSnapshot>
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)
})
})
})
+47 -1
View File
@@ -11817,6 +11817,50 @@ describe('OrcaRuntimeService', () => {
})
})
it('binds shell ownership evidence to the headless snapshot sequence', async () => {
const runtime = createRuntime()
let resolveConfirmation: ((confirmed: boolean) => void) | undefined
const confirmShellForeground = vi.fn(
() => new Promise<boolean>((resolve) => void (resolveConfirmation = resolve))
)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
confirmShellForeground
})
syncSinglePty(runtime, 'pty-1')
runtime.onPtyData('pty-1', '\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker', 100)
const shellSnapshotPromise = runtime.serializeHiddenOutputRecoveryBuffer('pty-1')
await vi.waitFor(() => expect(confirmShellForeground).toHaveBeenCalledTimes(1))
let snapshotSettled = false
void shellSnapshotPromise.then(() => {
snapshotSettled = true
})
await Promise.resolve()
expect(snapshotSettled).toBe(false)
resolveConfirmation?.(true)
const shellSnapshot = await shellSnapshotPromise
// Why alternateScreen stays true here: the mirror never rewrites its own
// model — without a daemon barrier injecting the reset in-stream (direct
// provider path), the snapshot publishes the poisoned mode alongside the
// proof and the renderer's dead-TUI branch grounds the pane.
expect(shellSnapshot).toMatchObject({
alternateScreen: true,
terminalOwner: 'shell',
seq: '\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker'.length
})
expect(confirmShellForeground).toHaveBeenCalledTimes(1)
runtime.onPtyData('pty-1', '\x1b]133;C\x07\x1b[?1049hLIVE-TUI', 101)
const liveSnapshot = await runtime.serializeHiddenOutputRecoveryBuffer('pty-1')
expect(liveSnapshot?.alternateScreen).toBe(true)
expect(liveSnapshot?.terminalOwner).toBeUndefined()
})
it('keeps an empty headless snapshot authoritative for hidden-output recovery', async () => {
const serializeBuffer = vi.fn().mockResolvedValue({
data: 'stale renderer content\r\n',
@@ -11843,6 +11887,7 @@ describe('OrcaRuntimeService', () => {
}
outputSequence: number
writeChain: Promise<void>
ownership: { settle: () => Promise<void>; owner: undefined }
}
const runtimePrivate = runtime as unknown as {
headlessTerminals: Map<string, HeadlessStateForTest>
@@ -11853,7 +11898,8 @@ describe('OrcaRuntimeService', () => {
getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 })
},
outputSequence: 17,
writeChain: Promise.resolve()
writeChain: Promise.resolve(),
ownership: { settle: async () => {}, owner: undefined }
})
await expect(runtime.serializeHiddenOutputRecoveryBuffer('pty-empty')).resolves.toEqual({
+51 -3
View File
@@ -1248,6 +1248,7 @@ import {
} from '../ipc/watcher-removal-drain'
import { withWorktreeSpan } from '../observability/instrumentation'
import { HeadlessEmulator } from '../daemon/headless-emulator'
import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror'
import {
isNativeWindowsConptyPty,
registerConptyDa1OverrideInstaller,
@@ -1919,6 +1920,7 @@ type RuntimeHeadlessTerminal = {
// the seq actually painted into this emulator, not the latest PTY seq.
outputSequence: number
writeChain: Promise<void>
ownership: PtyShellOwnershipMirror
}
export type RuntimePtyDataAdmission = Readonly<{
@@ -1972,6 +1974,7 @@ type RuntimeTerminalBufferSnapshot = {
/** Effective kitty flags proven at this snapshot's own `seq`. Absent means
* the winning source could not prove them. */
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
}
type HeadlessSeedMetadata = {
@@ -1983,6 +1986,7 @@ type HeadlessSeedMetadata = {
* emulator so hidden `CSI ? u` answers the real flags instead of ?0u
* (terminal-query-authority.md §kitty). */
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
}
type RuntimePtyController = {
@@ -2084,6 +2088,7 @@ type RuntimePtyController = {
ptyId: string
): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }>
confirmForegroundProcess?(ptyId: string): Promise<string | null>
confirmShellForeground?(ptyId: string): Promise<boolean>
hasChildProcesses?(ptyId: string): Promise<boolean>
clearBuffer?(ptyId: string): Promise<void>
resize?(ptyId: string, cols: number, rows: number): boolean
@@ -14389,6 +14394,7 @@ export class OrcaRuntimeService {
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
scrollbackAnsi?: string
terminalOwner?: 'shell'
} | null> {
return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true })
}
@@ -14409,6 +14415,7 @@ export class OrcaRuntimeService {
alternateScreen?: boolean
scrollbackAnsi?: string
pendingEscapeTailAnsi?: string
terminalOwner?: 'shell'
} | null> {
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, {
...opts,
@@ -14509,6 +14516,12 @@ export class OrcaRuntimeService {
if (metadata.oscLinks !== undefined) {
state.emulator.setRestoredOscLinks(metadata.oscLinks)
}
// Why derived from the emulator: the seed bytes bypass ownership.scan,
// so the scanner must inherit the restored alternate-screen state or a
// pane seeded mid-TUI never arms its recovery trigger.
state.ownership.seedOwner(metadata.terminalOwner, {
alternateScreen: state.emulator.isAlternateScreen
})
this.providerSnapshotPreferredPtys.delete(ptyId)
})
.catch(() => {
@@ -14616,6 +14629,9 @@ export class OrcaRuntimeService {
// (they feed main's tracker only), so its serializer lastTitle can be
// stale here. Prefer main's tracked title; the renderer's is only the
// seed when main has observed none (fresh relaunch, cold tracker).
state.ownership.seedOwner(undefined, {
alternateScreen: state.emulator.isAlternateScreen
})
const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle
if (seedTitle) {
state.emulator.setLastTitle(seedTitle)
@@ -14691,6 +14707,9 @@ export class OrcaRuntimeService {
const completion = state.writeChain.then(async () => {
// Why: the ingestion-time ownership decision is closed over this
// chain link; async scheduling cannot retroactively change it.
// Why inside the chain: the ownership mirror must observe live bytes in
// the same total order as seeds (seedOwner also runs on this chain).
state.ownership.scan(data)
await state.emulator.write(data, { forwardQueryReplies })
state.outputSequence = outputSequence
})
@@ -14750,7 +14769,28 @@ export class OrcaRuntimeService {
if (viewAttributes) {
emulator.applyPushedViewAttributes(viewAttributes)
}
state = { emulator, outputSequence: 0, writeChain: Promise.resolve() }
const constructed: RuntimeHeadlessTerminal = {
emulator,
outputSequence: 0,
writeChain: Promise.resolve(),
ownership: new PtyShellOwnershipMirror(async () => {
const controller = this.ptyController
const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId)
if (
!controller?.confirmShellForeground ||
this.headlessTerminals.get(ptyId) !== constructed
) {
return false
}
const confirmed = await controller.confirmShellForeground(ptyId)
return (
confirmed &&
this.headlessTerminals.get(ptyId) === constructed &&
this.getPtyLifecycleGeneration(ptyId) === lifecycleGeneration
)
})
}
state = constructed
return state
}
@@ -14802,6 +14842,9 @@ export class OrcaRuntimeService {
if (snapshot.oscLinks !== undefined) {
state.emulator.setRestoredOscLinks(snapshot.oscLinks)
}
state.ownership.seedOwner(snapshot.terminalOwner, {
alternateScreen: state.emulator.isAlternateScreen
})
state.outputSequence = snapshot.seq
})
.catch(() => {
@@ -14860,6 +14903,7 @@ export class OrcaRuntimeService {
alternateScreen?: boolean
pendingEscapeTailAnsi?: string
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
} | null> {
if (this.providerSnapshotPreferredPtys.has(ptyId)) {
// Why: pre-attach stream bytes only form a suffix of restored state. A
@@ -15340,6 +15384,7 @@ export class OrcaRuntimeService {
alternateScreen?: boolean
scrollbackAnsi?: string
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
// Why: dangling mid-escape tail the restorer must write LAST, after any
// reset, so the next live chunk completes it instead of rendering it
// literally (Bug E / #7329).
@@ -15350,11 +15395,12 @@ export class OrcaRuntimeService {
return null
}
await state.writeChain
await state.ownership.settle()
// Why: normal history is separated from an active alternate frame, so the
// caller's scrollback policy can be honored without painting it into alt.
const isAlternateScreen = state.emulator.isAlternateScreen
const scrollbackRows = opts.scrollbackRows ?? 0
const snapshot = state.emulator.getSnapshot({ scrollbackRows })
const terminalOwner = state.ownership.owner
const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi
return data.length > 0 || opts.includeEmpty === true
? this.preferTrackedLastTitle(ptyId, {
@@ -15377,10 +15423,11 @@ export class OrcaRuntimeService {
...(snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
: {}),
...(terminalOwner ? { terminalOwner } : {}),
// Why: lets the renderer skip the destructive scrollback clear when
// restoring an alt-screen snapshot — clearing wipes xterm's own
// history that the TUI relies on for scroll-up after a tab return.
alternateScreen: isAlternateScreen,
alternateScreen: snapshot.modes?.alternateScreen ?? state.emulator.isAlternateScreen,
// Why NOT folded into data: the renderer writes its post-replay
// reset after data, and any ESC after a dangling partial aborts it.
// The restorer writes this last (Bug E fix).
@@ -15400,6 +15447,7 @@ export class OrcaRuntimeService {
// sever the reply sink now so they cannot write to a respawned PTY that
// reused this id (belt to the sink's state-identity check).
state.emulator.disableQueryReplyForwarding()
state.ownership.dispose()
state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose())
}
@@ -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')
})
})
@@ -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<boolean>) {}
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<void> {
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<void>((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<boolean>
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()
}
}
}
@@ -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', {
@@ -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
}
@@ -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` : '')
@@ -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,
@@ -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
})
@@ -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 = {
@@ -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')
@@ -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
@@ -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
+2
View File
@@ -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
+2
View File
@@ -1060,6 +1060,7 @@ const api = {
snapshotFrameAnsi?: string
snapshotFrameRestoreAnsi?: string
snapshotKittyKeyboardFlags?: number
snapshotTerminalOwner?: 'shell'
snapshotSeq?: number
isReattach?: boolean
isAlternateScreen?: boolean
@@ -1190,6 +1191,7 @@ const api = {
scrollbackAnsi?: string
pendingEscapeTailAnsi?: string
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
} | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
getRendererDeliveryDebugSnapshot: (): Promise<{
@@ -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
@@ -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',
@@ -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
@@ -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,
@@ -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')
@@ -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()
}
@@ -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
@@ -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) {
@@ -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()
}
@@ -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)
}
@@ -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<void> {
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)
@@ -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
}
@@ -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)

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