mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
Merge remote-tracking branch 'origin/main' into tmp/native-chat-recovery-main-integration
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -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(() => {})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+37
@@ -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')
|
||||
|
||||
+181
-4
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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()
|
||||
}
|
||||
|
||||
+8
-1
@@ -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)
|
||||
}
|
||||
|
||||
+17
-6
@@ -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
Reference in New Issue
Block a user