From 3c79779ae0a884b8459245969230bbd8b3f439d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:00:49 +0000 Subject: [PATCH 001/118] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 0f935f89aca..d604b83bf97 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 29m + + downloads: 30m @@ -15,7 +15,7 @@ downloads downloads - 29m - 29m + 30m + 30m From 80246238fcc73ee2e9365c18b9830b7b7fbf9280 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:17:31 -0700 Subject: [PATCH 002/118] fix(mobile): reset recycled paragraph layout before reuse (#16692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): quantize chat pinch font scale so a zoom stops re-measuring the list every frame A user bubble on a 390pt iPhone painted five lines inside a frame that reserved six, with the last painted line cut through a glyph at the content edge and "no longer needed." gone. The paint is React Native's: a `` with no `numberOfLines` gets a text container whose `lineBreakMode` is `NSLineBreakByClipping` (RCTTextLayoutManager.mm). Measure lays out into `{width, CGFLOAT_MAX}`, paint lays out into the mounted content frame — so a frame one line short does not re-wrap, it dumps the remainder onto the last fitting line and clips it, with no ellipsis. Reproduced on-device against the real component with the message text held constant, so five painted lines can only be truncation. Two conditions are each necessary, and removing either makes it vanish over ~6000 measured bubble renders: a pooled `RCTParagraphComponentView` carrying a shorter row's content frame (`prepareForRecycle` clears `state` but not `_textView.layoutMetrics`), and whole-list re-measure churn while rows enter and leave that pool. The churn was ours. `renderItem` closes over `fontScale`, and the pinch handler committed a new scale on every gesture frame, so one zoom drove hundreds of full-list re-measures. The pinch is composed `Simultaneous` with the list's own scroll, so a stray second finger during a scroll started that storm at scales the user cannot see — matching the report, whose glyph metrics are `fontScale` 1.0 exactly. `quantizeFontScale` snaps commits to a 5% grid. React bails out of a same-value `setState`, so gesture noise now commits nothing and a full-range pinch commits at most ~20 times. Under the churn that produced 67 defects in 6636 bubble renders, the quantized build measured 0 in 6064 — with a forced-defect control bubble flagged in 100% of frames of both runs to prove the detector was live. This removes the trigger we own; it does not close the RN recycling window itself. That needs a one-line reset in `prepareForRecycle`, which cannot land here without refreshing the `patchedDependencies` hash under `mobile/`. * fix(mobile): reset recycled paragraph layout before reuse --- mobile/patches/react-native@0.83.9.patch | 12 +++ mobile/pnpm-lock.yaml | 2 +- .../mobile-native-chat-message-text.ts | 25 +++++ ...e-mobile-native-chat-pinch-gesture.test.ts | 91 +++++++++++++++++++ .../use-mobile-native-chat-pinch-gesture.ts | 8 +- 5 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts diff --git a/mobile/patches/react-native@0.83.9.patch b/mobile/patches/react-native@0.83.9.patch index 102b052746b..9bda1b252fa 100644 --- a/mobile/patches/react-native@0.83.9.patch +++ b/mobile/patches/react-native@0.83.9.patch @@ -46,3 +46,15 @@ index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..c9d34f8b42644800b5c85e5be45b6ebf AttributedString::Range selectionRange; // ScrollView-like metrics Size contentSize; +diff --git a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +index 79ee7ffe43..956f189a1c 100644 +--- a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm ++++ b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +@@ -136,6 +136,7 @@ using namespace facebook::react; + - (void)prepareForRecycle + { + [super prepareForRecycle]; + _textView.state = nullptr; ++ _textView.layoutMetrics = EmptyLayoutMetrics; + _accessibilityProvider = nil; + } diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 56e64e1d442..01092f2acc9 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: patchedDependencies: react-native@0.83.9: - hash: a6502e7d63769bf1fd639cbbaab1cc0b8aebef31d22048e8f2186fb399d0c16e + hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d path: patches/react-native@0.83.9.patch importers: diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts index 227488a56df..a94104835a9 100644 --- a/mobile/src/session/mobile-native-chat-message-text.ts +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -21,3 +21,28 @@ export function clampFontScale(scale: number): number { } return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale)) } + +/** Granularity a pinch is allowed to commit at. */ +export const FONT_SCALE_STEP = 0.05 + +/** Snap a proposed font scale to the nearest committed step. + * + * Why quantize: the chat's `renderItem` closes over `fontScale`, so every + * distinct value re-renders and re-measures every message in the list. A raw + * pinch proposes a new scale on each gesture frame, so a single zoom drives + * hundreds of whole-list re-measures — and because the pinch is composed + * `Simultaneous` with the list's own scroll, an incidental second finger during + * a scroll starts that storm at scales indistinguishable from 1. Those + * re-measures run while rows are mounting and unmounting, which is when a + * recycled iOS paragraph view can repaint with the previous row's content + * frame and silently drop the tail of a long message. Snapping to steps keeps a + * full-range pinch to at most 20 commits and makes a near-neutral pinch commit + * nothing at all. */ +export function quantizeFontScale(scale: number): number { + if (Number.isNaN(scale)) { + return 1 + } + const stepped = Math.round(clampFontScale(scale) / FONT_SCALE_STEP) * FONT_SCALE_STEP + // Re-clamp: rounding can push the outermost step past the bound. + return clampFontScale(Number(stepped.toFixed(2))) +} diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts new file mode 100644 index 00000000000..8f34b6da9d5 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts @@ -0,0 +1,91 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FONT_SCALE_STEP, quantizeFontScale } from './mobile-native-chat-message-text' + +const handlers: { start?: () => void; update?: (e: { scale: number }) => void } = {} + +vi.mock('react-native-gesture-handler', () => { + const pinch = { + runOnJS: () => pinch, + onStart: (cb: () => void) => { + handlers.start = cb + return pinch + }, + onUpdate: (cb: (e: { scale: number }) => void) => { + handlers.update = cb + return pinch + } + } + return { + Gesture: { Simultaneous: (...g: unknown[]) => g, Native: () => ({}), Pinch: () => pinch } + } +}) + +import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' + +describe('quantizeFontScale', () => { + it('snaps to the commit step and stays inside the supported range', () => { + expect(quantizeFontScale(1)).toBe(1) + expect(quantizeFontScale(1.01)).toBe(1) + expect(quantizeFontScale(1.024)).toBe(1) + expect(quantizeFontScale(1.03)).toBe(1.05) + expect(quantizeFontScale(5)).toBe(1.8) + expect(quantizeFontScale(0.1)).toBe(0.8) + expect(quantizeFontScale(Number.NaN)).toBe(1) + }) + + it('collapses a full-range pinch to at most one commit per step', () => { + const committed = new Set() + for (let i = 0; i <= 600; i++) { + committed.add(quantizeFontScale(0.7 + i * 0.002)) + } + expect(committed.size).toBeLessThanOrEqual(Math.round(1 / FONT_SCALE_STEP) + 1) + }) +}) + +describe('useMobileNativeChatPinchGesture', () => { + let renderer: { unmount: () => void } | null = null + let latest = 1 + + function Probe(): null { + latest = useMobileNativeChatPinchGesture().fontScale + return null + } + + beforeEach(() => { + latest = 1 + act(() => { + renderer = create(createElement(Probe)) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + // Why this matters: `renderItem` closes over `fontScale`, so each committed + // value re-measures every message in the list. Committing raw gesture scales + // drives hundreds of whole-list re-measures per pinch, and a stray second + // finger during an ordinary scroll starts that storm at scales the user cannot + // even see. Those re-measures are what expose the recycled-paragraph repaint + // that silently drops the tail of a long message. + it('commits nothing for gesture noise the user cannot see', () => { + act(() => handlers.start?.()) + for (const scale of [1.001, 0.995, 1.008, 1.02, 0.982]) { + act(() => handlers.update?.({ scale })) + } + expect(latest).toBe(1) + }) + + it('commits a real pinch, on the step grid', () => { + act(() => handlers.start?.()) + act(() => handlers.update?.({ scale: 1.34 })) + expect(latest).toBe(1.35) + act(() => handlers.update?.({ scale: 1.37 })) + expect(latest).toBe(1.35) + act(() => handlers.update?.({ scale: 1.42 })) + expect(latest).toBe(1.4) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts index 3b3cdb2e8a7..1aebada4f4d 100644 --- a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts @@ -1,7 +1,7 @@ import { useMemo, useRef, useState } from 'react' import { Gesture } from 'react-native-gesture-handler' import type { ComposedGesture } from 'react-native-gesture-handler' -import { clampFontScale } from './mobile-native-chat-message-text' +import { quantizeFontScale } from './mobile-native-chat-message-text' /** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase` * anchors the live gesture so successive pinches compound rather than reset. */ @@ -14,8 +14,8 @@ export function useMobileNativeChatPinchGesture(): { fontScaleRef.current = fontScale const pinchBase = useRef(1) // Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so - // they can touch React refs/state and clampFontScale directly — accessing those - // from the UI-thread worklet crashes the app. + // they can touch React refs/state and quantizeFontScale directly — accessing + // those from the UI-thread worklet crashes the app. // Compose the pinch with the list's native scroll as Simultaneous so a // two-finger pinch is recognized even while the scroll view is active — // otherwise the scroll grabs the gesture first and the pinch never fires. @@ -29,7 +29,7 @@ export function useMobileNativeChatPinchGesture(): { pinchBase.current = fontScaleRef.current }) .onUpdate((e) => { - setFontScale(clampFontScale(pinchBase.current * e.scale)) + setFontScale(quantizeFontScale(pinchBase.current * e.scale)) }) ), [] From d60a3c900b0cfe63611a6f0a2ab0e41cc438939d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:49:26 -0700 Subject: [PATCH 003/118] Reset stale terminal modes after dead TUI replay (#16379) --- config/reliability-gates.jsonc | 123 ++++++ src/main/daemon/daemon-checkpoint-file.ts | 2 + ...mon-durable-history-ownership-seed.test.ts | 41 ++ .../daemon/daemon-durable-history-snapshot.ts | 34 ++ .../daemon-foreground-process-protocol.ts | 4 + .../daemon/daemon-pty-adapter-test-harness.ts | 2 + src/main/daemon/daemon-pty-adapter.test.ts | 31 ++ .../daemon/daemon-pty-buffer-snapshots.ts | 3 +- .../daemon/daemon-pty-process-inspection.ts | 11 + src/main/daemon/daemon-pty-router.ts | 4 + src/main/daemon/daemon-pty-spawn-result.ts | 3 + src/main/daemon/daemon-request-router.ts | 8 +- .../daemon-restore-scrollback-depth.test.ts | 33 ++ .../daemon/degraded-daemon-pty-provider.ts | 3 + .../foreground-process-tracker.ts | 6 +- .../pty-shell-foreground-confirmation.ts | 24 ++ .../pty-subprocess/subprocess-handle.ts | 1 + src/main/daemon/session-output-pipeline.ts | 33 ++ src/main/daemon/session-output-plane.ts | 39 +- .../daemon/session-shell-recovery.test.ts | 156 +++++++ src/main/daemon/session-subprocess-handle.ts | 2 + src/main/daemon/session.test.ts | 50 +++ src/main/daemon/session.ts | 68 +-- .../terminal-checkpoint-serializer.test.ts | 19 + .../daemon/terminal-checkpoint-serializer.ts | 8 +- .../terminal-history-checkpoint-reader.ts | 5 +- .../terminal-history-cold-restore-info.ts | 6 +- .../terminal-host-concurrent-create.test.ts | 50 ++- .../daemon/terminal-host-session-create.ts | 6 + src/main/daemon/terminal-host.ts | 26 ++ .../terminal-shell-clean-exit-confirmation.ts | 100 +++++ .../terminal-shell-lifecycle-scanner.test.ts | 285 ++++++++++++ .../terminal-shell-lifecycle-scanner.ts | 223 ++++++++++ .../terminal-shell-recovery-barrier.test.ts | 405 ++++++++++++++++++ .../daemon/terminal-shell-recovery-barrier.ts | 338 +++++++++++++++ ...ell-recovery-clean-exit-retirement.test.ts | 72 ++++ src/main/daemon/terminal-snapshot.ts | 3 + src/main/daemon/types.ts | 3 + src/main/ipc/pty/ipc/snapshot.ts | 1 + src/main/ipc/pty/ipc/spawn-commit.ts | 14 +- src/main/ipc/pty/runtime/controller.ts | 2 + src/main/ipc/pty/runtime/operations.ts | 8 + ...ound-process-windows-job-rejection.test.ts | 21 + .../agent-foreground-process.test.ts | 101 +++++ .../providers/agent-foreground-process.ts | 63 +++ src/main/providers/local-pty-provider.ts | 20 +- src/main/providers/pty-provider-contract.ts | 5 + src/main/providers/pty-spawn-result.ts | 3 + src/main/providers/ssh-pty-provider.test.ts | 7 + ...bile-session-tabs-churn-coalescing.test.ts | 179 ++++++++ .../mobile-subscribe-integration.test.ts | 108 +---- src/main/runtime/orca-runtime.test.ts | 48 ++- src/main/runtime/orca-runtime.ts | 54 ++- .../pty-shell-ownership-mirror.test.ts | 88 ++++ .../runtime/pty-shell-ownership-mirror.ts | 149 +++++++ .../terminal-legacy-subscribe-snapshot.ts | 2 + .../terminal-multiplex-flow-control.ts | 2 + .../terminal-multiplex-initial-snapshot.ts | 2 + .../terminal-multiplex-slot-frames.ts | 2 + .../terminal/terminal-snapshot-publication.ts | 11 + .../methods/terminal/terminal-stream-types.ts | 4 + .../terminal-multiplex-escape-tail.test.ts | 9 +- .../rpc/terminal-subscribe-buffer.test.ts | 8 +- src/preload/api/pty-api.ts | 2 + src/preload/index.ts | 2 + .../terminal-pane/ipc-pty-connect-result.ts | 3 + ...-connection-daemon-snapshot-replay.test.ts | 37 ++ ...ty-connection-hidden-codex-queries.test.ts | 185 +++++++- ...pty-connection-reattach-mode-reset.test.ts | 40 ++ .../pty-connection/apply-reattach-payload.ts | 6 +- .../deferred-cold-restore-and-snapshot.ts | 4 +- .../fresh-spawn-follow-reset.ts | 9 +- .../hidden-output-restore-snapshot.ts | 23 +- .../pty-connection/replay-data-drain.ts | 7 +- .../terminal-pane/pty-output-processor.ts | 8 +- .../terminal-pane/pty-transport-types.ts | 4 + ...e-runtime-pty-snapshot-escape-tail.test.ts | 10 +- .../remote-runtime-pty-transport.ts | 6 + .../terminal-replay-cursor-state.test.ts | 30 ++ .../remote-runtime-terminal-multiplexer.ts | 23 +- src/shared/terminal-mode-reset-profiles.ts | 4 + src/shared/terminal-owner.ts | 5 + .../cross-version-terminal-wire.unit.test.ts | 14 + .../host-terminal-runtime-stub.ts | 12 +- ...l-hidden-child-tui-kill-mode-reset.spec.ts | 220 ++++++++++ 85 files changed, 3612 insertions(+), 183 deletions(-) create mode 100644 src/main/daemon/daemon-durable-history-ownership-seed.test.ts create mode 100644 src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts create mode 100644 src/main/daemon/session-output-pipeline.ts create mode 100644 src/main/daemon/session-shell-recovery.test.ts create mode 100644 src/main/daemon/terminal-shell-clean-exit-confirmation.ts create mode 100644 src/main/daemon/terminal-shell-lifecycle-scanner.test.ts create mode 100644 src/main/daemon/terminal-shell-lifecycle-scanner.ts create mode 100644 src/main/daemon/terminal-shell-recovery-barrier.test.ts create mode 100644 src/main/daemon/terminal-shell-recovery-barrier.ts create mode 100644 src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts create mode 100644 src/main/providers/agent-foreground-process-windows-job-rejection.test.ts create mode 100644 src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts create mode 100644 src/main/runtime/pty-shell-ownership-mirror.test.ts create mode 100644 src/main/runtime/pty-shell-ownership-mirror.ts create mode 100644 src/shared/terminal-owner.ts create mode 100644 tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0826c5dea46..8a6dc2b0d96 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5063,6 +5063,129 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-session.shell-owned-mode-recovery", + "title": "Dead TUI mode recovery requires execution-host shell ownership", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "host-renderer-electron", + "surfaces": [ + "alternate-screen lifecycle", + "mouse input routing", + "hidden-output snapshot restore", + "daemon reattach", + "remote-runtime replay", + "durable terminal history" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon", "remote-runtime"], + "coverageNotes": "Deterministic host/parser, process-classifier, snapshot-publication, mixed-version, renderer, and macOS Electron coverage proves the local and shared paired-runtime contracts. Direct SSH intentionally publishes no shell proof and immediately preserves incumbent behavior. WSL, Windows ConPTY, Linux desktop, and paired headed/headless journeys remain gaps.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/16379"], + "invariant": "After a TUI exits or is killed, reveal, reattach, snapshot replay, or renderer remount must not deliver terminal-owned mouse or alternate-screen protocol bytes to the surviving shell. Recovery is an ordered output barrier in the daemon session data path: an OSC 133;D completing while the alternate screen is still active pauses the stream at that exact byte boundary, a fresh execution-host process inspection proves shell ownership, and on proof a mode reset is injected as in-stream output so every consumer converges by parsing the same bytes and the queued post-boundary shell output (the prompt) lands on the normal buffer. Snapshots are pure reads. Any failure — refuted proof, timeout, queue overflow, session death, disposal — flushes the queue unmodified, preserving incumbent behavior; later command or mode bytes revoke proof. Clean alternate-screen exits prove ownership asynchronously without pausing.", + "oracle": "Run one fixed child-TUI journey for normal exit and cleanup-free SIGKILL. Assert renderer and host normal-buffer/non-mouse state, host snapshot terminalOwner metadata, exact PTY writes with no post-exit mouse report, unrelated-pane survival, post-boundary prompt output preserved (normal exit), ordered proof invalidation, bounded settlement and bail-out flush, one inspection per unclean episode with zero scans for ordinary output, split-escape safety at every chunk boundary, and old/new client-host fallback parity.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/terminal-shell-lifecycle-scanner.test.ts src/main/daemon/terminal-shell-recovery-barrier.test.ts src/main/daemon/session-shell-recovery.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-restore-scrollback-depth.test.ts src/main/daemon/terminal-checkpoint-serializer.test.ts src/main/providers/agent-foreground-process.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/mobile-subscribe-integration.test.ts src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts", + "src/main/daemon/terminal-shell-recovery-barrier.test.ts", + "src/main/daemon/session-shell-recovery.test.ts", + "src/main/daemon/session.test.ts", + "src/main/daemon/terminal-host-concurrent-create.test.ts", + "src/main/daemon/daemon-pty-adapter.test.ts", + "src/main/daemon/daemon-restore-scrollback-depth.test.ts", + "src/main/daemon/terminal-checkpoint-serializer.test.ts", + "src/main/providers/agent-foreground-process.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/mobile-subscribe-integration.test.ts", + "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts", + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts", + "tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts", + "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/terminal-shell-recovery-barrier.test.ts", + "assertions": [ + "an unclean-death boundary pauses the stream, injects the reset in order, and delivers the queued prompt after it", + "refuted proof, timeout, overflow, death, and disposal all flush the queue unmodified with no injection", + "clean alternate-screen exits prove ownership without pausing and later command starts revoke proof", + "a second unclean episode queued behind the first resolves in order", + "split triggers across emissions split the completing emission at the exact sequence boundary" + ] + }, + { + "file": "src/main/daemon/session-shell-recovery.test.ts", + "assertions": [ + "post-kill shell output survives into the normalized snapshot instead of the discarded alt buffer", + "a snapshot taken during a split escape keeps the pending tail intact and stale proof is revoked by the completing bytes" + ] + }, + { + "file": "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts", + "assertions": [ + "normal exit and cleanup-free SIGKILL both restore a visible clean shell marker", + "renderer logical buffer is normal and mouse tracking is disabled after host-proven recovery", + "real pointer input writes no SGR mouse report to the surviving shell", + "an unrelated pane remains live" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 33.8, + "summary": "Both normal-exit and SIGKILL journeys passed on the ordered-output-barrier implementation; each asserted renderer and host normal modes, host ownership metadata, exact PTY input, no post-exit SGR mouse report under real pointer input, and unrelated-pane survival, with post-boundary prompt output additionally proven visible on normal exit." + } + ], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "focused host/renderer contracts, mixed-version journey, and two-case Electron run" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "The deterministic focused suites and two-case Electron journey pass locally; CI soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical Electron journey reproduced screenshot-like numeric mouse-report residue and a real SGR mouse packet reaching the shell before the focused action. With only the renderer's host-proven reset disabled, normal cleanup failed in normal-buffer/any-event mouse state and SIGKILL failed in alternate-buffer/any-event mouse state. Restoring the action made both cases green." + }, + "performanceBudget": { + "required": true, + "evidence": "No reveal timer, polling, periodic scan, broad session inventory, or renderer remount is added, and snapshots perform no writes. The stream pauses only for an unclean alternate-screen death (never for clean TUI exits or ordinary output), bounded by a 750 ms bail timer and a 256 KiB queue cap that both flush unmodified. The event-triggered fresh host inspection measured 30 local macOS samples at 84.6 ms median, 103.7 ms p95, and 124.5 ms maximum; the paired runtime asks the daemon for its settled verdict instead of running a second inspection." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the real journey on Linux and Windows, including ConPTY and WSL.", + "Add headed and headless paired-runtime journey evidence.", + "Keep missing-proof mixed-version behavior identical to the incumbent client path." + ], + "knownGaps": [ + "A zero-width injected reset does not advance outputSequence, so the mobile renderer-snapshot stability oracle cannot detect a reset landing inside its serialize round-trip; the resulting tear is transient and self-heals on the next repaint (same exposure as the pre-existing device-attributes release emission).", + "Bytes the dying command printed before its OSC 133;D (pre-boundary output into the still-active alternate screen, e.g. a final panic line) are discarded with the dead frame; only post-boundary shell output is preserved.", + "Direct local provider and degraded (daemon-less) sessions get ownership proof and the renderer-side reset, but no in-stream normalization — pre-reveal prompt output is not preserved there.", + "Visible Electron evidence is macOS-only.", + "Direct SSH intentionally fails closed to current-main behavior because foreground proof would inherit transport timeout and enumeration ambiguity.", + "WSL and Windows ConPTY process-membership behavior has no physical-host proof yet.", + "Any stopped (Ctrl-Z) descendant in the shell's tree refutes the proof, so a suspended unrelated job silently disables recovery for that pane (fails closed to incumbent behavior).", + "Attach is deliberately settle-free: a viewer connecting inside an in-flight proof (~100ms typical) receives the pre-reset snapshot as its first frame and self-heals when the in-stream reset arrives; snapshot RPC and remote serialize paths still inherit a bounded (750ms) settle wait while a proof is in flight.", + "Paired headed and headless remote-runtime topologies have not yet run the full pointer-input oracle." + ], + "demotionRule": "Keep experimental or demote if missing ownership proof changes incumbent behavior, a live TUI loses mouse/focus modes, foreground inspection enters ordinary output/reveal paths, or the focused Electron journey flakes without an identified product or harness fault." + }, { "id": "terminal-session.layout-pty-ownership", "title": "Restored terminal layouts retain one renderer owner per PTY", diff --git a/src/main/daemon/daemon-checkpoint-file.ts b/src/main/daemon/daemon-checkpoint-file.ts index 8031996a54c..a1dd53fc898 100644 --- a/src/main/daemon/daemon-checkpoint-file.ts +++ b/src/main/daemon/daemon-checkpoint-file.ts @@ -1,5 +1,6 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalModes } from './types' +import type { TerminalOwner } from '../../shared/terminal-owner' /** On-disk shape of checkpoint.json. Written by history-manager, read by * history-reader — one type so the generation pairing with output.log's @@ -17,6 +18,7 @@ export type TerminalCheckpointFile = { modes: TerminalModes scrollbackLines: number lastTitle?: string + terminalOwner?: TerminalOwner /** Ties this checkpoint to the output.log whose header carries the same * generation. Absent on checkpoints written before incremental logs. */ generation?: number diff --git a/src/main/daemon/daemon-durable-history-ownership-seed.test.ts b/src/main/daemon/daemon-durable-history-ownership-seed.test.ts new file mode 100644 index 00000000000..0e5fc0929ae --- /dev/null +++ b/src/main/daemon/daemon-durable-history-ownership-seed.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' +import type { TerminalSnapshot } from './types' + +const liveSnapshot = { + snapshotAnsi: 'prompt', + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/tmp', + cols: 80, + rows: 24, + scrollbackLines: 0, + modes: { + bracketedPaste: false, + alternateScreen: false, + applicationCursor: false, + mouseTracking: false + }, + terminalOwner: 'shell', + outputSequence: 7 +} as TerminalSnapshot + +describe('durable checkpoint ownership seeding without disk history', () => { + it('carries the live proof through a rebuild over benign pending records', async () => { + const durable = await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo: null, + pendingRecords: [{ kind: 'output', data: 'plain shell prompt\r\n' }] + }) + expect(durable.terminalOwner).toBe('shell') + }) + + it('revokes the live proof when pending records re-enter a TUI', async () => { + const durable = await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo: null, + pendingRecords: [{ kind: 'output', data: '\x1b[?1049hLIVE-TUI' }] + }) + expect(durable.terminalOwner).toBeUndefined() + }) +}) diff --git a/src/main/daemon/daemon-durable-history-snapshot.ts b/src/main/daemon/daemon-durable-history-snapshot.ts index c8d71736607..bc99fb566ce 100644 --- a/src/main/daemon/daemon-durable-history-snapshot.ts +++ b/src/main/daemon/daemon-durable-history-snapshot.ts @@ -1,6 +1,7 @@ import { ColdRestoreReplayWriter } from './cold-restore-replay-writer' import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' import { HeadlessEmulator } from './headless-emulator' +import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' import { isValidTerminalHistorySize } from './terminal-history-dimensions' import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' import type { PendingOutputRecord, TerminalSnapshot } from './types' @@ -12,6 +13,7 @@ type RestoreBase = { pendingEscapeTailAnsi?: string oscLinks?: TerminalSnapshot['oscLinks'] lastTitle?: string + terminalOwner?: TerminalSnapshot['terminalOwner'] cwd: string | null cols: number rows: number @@ -35,6 +37,7 @@ export function terminalSnapshotFromColdRestore( scrollbackLines: info.scrollbackLines ?? Math.max(0, countAnsiRows(info.scrollbackAnsi) - info.rows), ...(info.lastTitle ? { lastTitle: info.lastTitle } : {}), + ...(info.terminalOwner ? { terminalOwner: info.terminalOwner } : {}), ...(opts?.outputSequence !== undefined ? { outputSequence: opts.outputSequence } : {}) } } @@ -91,12 +94,29 @@ export async function buildDurableCheckpointSnapshot(opts: { } emulator.setCwd(base.cwd) } + // Why a scanner, not emulator state: ownership is lifecycle evidence bound + // to byte order — newer replayed output (a TUI starting) must revoke a + // persisted proof exactly as it would have live. + const ownershipScanner = new TerminalShellLifecycleScanner() + // Why the live fallback: with no disk history the live snapshot holds the + // only persisted proof for this boundary; the re-scan below can revoke it + // but never mint one, so the result is never less safe than dropping it. + ownershipScanner.seedOwner( + opts.restoreInfo ? opts.restoreInfo.terminalOwner : opts.liveSnapshot.terminalOwner + ) if (!(await replayPendingRecords(replay, pendingRecords))) { return opts.liveSnapshot } + for (const record of pendingRecords) { + if (record.kind === 'output') { + scanAllForOwnership(ownershipScanner, record.data) + } + } const snapshot = emulator.getSnapshot() + const terminalOwner = ownershipScanner.owner return { ...snapshot, + ...(terminalOwner ? { terminalOwner } : {}), ...(opts.liveSnapshot.outputSequence !== undefined ? { outputSequence: opts.liveSnapshot.outputSequence } : {}), @@ -122,12 +142,26 @@ function restoreBaseFrom(restoreInfo: ColdRestoreInfo): RestoreBase { : {}), oscLinks: restoreInfo.oscLinks, lastTitle: restoreInfo.lastTitle, + terminalOwner: restoreInfo.terminalOwner, cwd: restoreInfo.cwd, cols: restoreInfo.cols, rows: restoreInfo.rows } } +/** Replay can only carry or revoke a persisted proof, never mint one: the + * scanner has no confirmation source, so triggers are consumed as revocations. */ +function scanAllForOwnership(scanner: TerminalShellLifecycleScanner, data: string): void { + let rest = data + while (rest.length > 0) { + const events = scanner.scan(rest) + if (events.uncleanDeathTriggerEnd === undefined) { + return + } + rest = rest.slice(events.uncleanDeathTriggerEnd) + } +} + async function replayPendingRecords( replay: ColdRestoreReplayWriter, records: readonly PendingOutputRecord[] diff --git a/src/main/daemon/daemon-foreground-process-protocol.ts b/src/main/daemon/daemon-foreground-process-protocol.ts index c0d672ec159..a16d6464e88 100644 --- a/src/main/daemon/daemon-foreground-process-protocol.ts +++ b/src/main/daemon/daemon-foreground-process-protocol.ts @@ -10,6 +10,10 @@ export type ConfirmForegroundProcessRequest = Omit & { + type: 'confirmShellForeground' +} + export type InspectProcessRequest = Omit & { type: 'inspectProcess' } diff --git a/src/main/daemon/daemon-pty-adapter-test-harness.ts b/src/main/daemon/daemon-pty-adapter-test-harness.ts index 0e89847e1e5..f7676bbec2f 100644 --- a/src/main/daemon/daemon-pty-adapter-test-harness.ts +++ b/src/main/daemon/daemon-pty-adapter-test-harness.ts @@ -29,6 +29,7 @@ export function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle write: ReturnType void>> pause: ReturnType void>> resume: ReturnType void>> + confirmShellForeground: ReturnType Promise>> _simulateData: (data: string) => void _simulateExit: (code: number) => void } { @@ -38,6 +39,7 @@ export function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle // Why: getCwd falls back to OS pid lookup; an implausibly-high fake pid can't collide with a real process' cwd. pid: 999_999_999, getForegroundProcess: vi.fn(() => null), + confirmShellForeground: vi.fn(async () => false), write: vi.fn(), resize: vi.fn(), pause: vi.fn<() => void>(), diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index ef5fa687586..387036dd6c3 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -496,6 +496,37 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('getBufferSnapshot', () => { + it('publishes shell ownership only after the daemon proves the live PTY tree', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess.confirmShellForeground.mockResolvedValue(true) + + lastSubprocess._simulateData( + '\x1b[?1049h\x1b[?1003h\x1b[?1006hTUI\x1b]133;D;137\x07shell-marker' + ) + + await vi.waitFor(async () => { + await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({ + alternateScreen: false, + terminalOwner: 'shell' + }) + }) + await expect(adapter.confirmShellForeground(id)).resolves.toBe(true) + expect(lastSubprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + }) + + it('preserves live TUI modes when the daemon cannot prove shell ownership', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + + lastSubprocess._simulateData( + '\x1b[?1049h\x1b[?1003h\x1b[?1006hLIVE-TUI\x1b]133;D;0\x07nested-shell' + ) + + await vi.waitFor(() => expect(lastSubprocess.confirmShellForeground).toHaveBeenCalledTimes(1)) + const snapshot = await adapter.getBufferSnapshot(id) + expect(snapshot?.alternateScreen).toBe(true) + expect(snapshot?.terminalOwner).toBeUndefined() + }) + it('returns the daemon model with its absolute stream sequence', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) lastSubprocess._simulateData('complete hidden output\r\n') diff --git a/src/main/daemon/daemon-pty-buffer-snapshots.ts b/src/main/daemon/daemon-pty-buffer-snapshots.ts index cf65d85fa61..9fc0d10711e 100644 --- a/src/main/daemon/daemon-pty-buffer-snapshots.ts +++ b/src/main/daemon/daemon-pty-buffer-snapshots.ts @@ -64,7 +64,8 @@ export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl { ...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}), ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } - : {}) + : {}), + ...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {}) } } diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index d1dd3ad4e36..bbc44568501 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -72,6 +72,17 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot } } + async confirmShellForeground(id: string): Promise { + try { + const result = await this.client.request<{ confirmed: boolean }>('confirmShellForeground', { + sessionId: id + }) + return result.confirmed === true + } catch { + return false + } + } + async serialize(ids: string[]): Promise { const sessions: Record = {} for (const id of ids) { diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 0da991b6931..ab30878c9d8 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -185,6 +185,10 @@ export class DaemonPtyRouter implements IPtyProvider { return this.adapterFor(id).confirmForegroundProcess(id) } + async confirmShellForeground(id: string): Promise { + return (await this.adapterFor(id).confirmShellForeground?.(id)) ?? false + } + async serialize(ids: string[]): Promise { return this.current.serialize(ids) } diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index c40e8b6a9bd..f28750141f3 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -313,6 +313,9 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { ...(kittyKeyboardFlags !== undefined ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } : {}), + ...(reattachSnapshot.terminalOwner + ? { snapshotTerminalOwner: reattachSnapshot.terminalOwner } + : {}), isReattach: true, isAlternateScreen: isAltScreen, // Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch. diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 826c73b4370..1d5b75f6657 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -113,6 +113,10 @@ export class DaemonRequestRouter { request.payload.sessionId ) } + case 'confirmShellForeground': + return { + confirmed: await this.options.host.confirmShellForeground(request.payload.sessionId) + } case 'clearScrollback': this.options.host.clearScrollback(request.payload.sessionId) return {} @@ -213,13 +217,13 @@ export class DaemonRequestRouter { return { retiring } } - private getSnapshot(sessionId: string, requestedRows: unknown): unknown { + private async getSnapshot(sessionId: string, requestedRows: unknown): Promise { const startedAt = performance.now() const scrollbackRows = typeof requestedRows === 'number' && Number.isFinite(requestedRows) ? Math.max(0, Math.min(50_000, Math.floor(requestedRows))) : undefined - const snapshot = this.options.host.getSnapshot(sessionId, { scrollbackRows }) + const snapshot = await this.options.host.getSettledSnapshot(sessionId, { scrollbackRows }) const snapshotMs = performance.now() - startedAt if (snapshotMs >= 25) { recordDaemonStreamBacklogEvent('slowGetSnapshot', { diff --git a/src/main/daemon/daemon-restore-scrollback-depth.test.ts b/src/main/daemon/daemon-restore-scrollback-depth.test.ts index e6fe7ca35ff..b2c2c7c5028 100644 --- a/src/main/daemon/daemon-restore-scrollback-depth.test.ts +++ b/src/main/daemon/daemon-restore-scrollback-depth.test.ts @@ -15,6 +15,7 @@ import { HistoryManager } from './history-manager' import { HistoryReader } from './history-reader' import { TerminalHost } from './terminal-host' import type { DaemonFileLog } from './daemon-file-log' +import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' import type { PendingOutputRecord, TerminalSnapshot } from './types' import type { SubprocessHandle } from './session-subprocess-handle' @@ -211,6 +212,38 @@ describe('STA-4091 previously recoverable restore depth', () => { } }) + it('revokes persisted shell ownership when newer output starts a TUI', async () => { + const restoreInfo: ColdRestoreInfo = { + snapshotAnsi: 'stale-tui', + scrollbackAnsi: '', + rehydrateSequences: '\x1b[?1049h', + cwd: '/tmp', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: true, + applicationCursor: false, + alternateScreen: true + }, + terminalOwner: 'shell' + } + const liveSnapshot = { + ...restoreInfo, + scrollbackLines: 0, + outputSequence: 42 + } as TerminalSnapshot + + const durable = await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo, + pendingRecords: [{ kind: 'output', data: '\x1b[?1049hLIVE-TUI' }] + }) + + expect(durable.modes.alternateScreen).toBe(true) + expect(durable.terminalOwner).toBeUndefined() + }) + it('falls back to the live window when pending resize records are invalid', async () => { const liveSnapshot = { snapshotAnsi: 'live-only', diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index afd603c3943..f0e183bcb20 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -194,6 +194,9 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { async confirmForegroundProcess(id: string): Promise { return this.providerFor(id).confirmForegroundProcess?.(id) ?? null } + async confirmShellForeground(id: string): Promise { + return (await this.providerFor(id).confirmShellForeground?.(id)) ?? false + } async serialize(ids: string[]): Promise { return this.fallback.serialize(ids) diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts index 3cf04164d28..115b795202d 100644 --- a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -1,6 +1,7 @@ import type * as pty from 'node-pty' import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process' +import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation' import { judgeCachedAgentJobEvidence, WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS @@ -37,6 +38,7 @@ export type PtyForegroundProcessTracker = { markDead(): void getForegroundProcess(): string | null confirmForegroundProcess(): Promise + confirmShellForeground(): Promise } export function createPtyForegroundProcessTracker(args: { @@ -306,6 +308,8 @@ export function createPtyForegroundProcessTracker(args: { } catch { return null } - } + }, + confirmShellForeground: () => + confirmPtyShellForeground({ process: proc, shellPath: args.shellPath, isDead: args.isDead }) } } diff --git a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts new file mode 100644 index 00000000000..2fdd75e3518 --- /dev/null +++ b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts @@ -0,0 +1,24 @@ +import type * as pty from 'node-pty' +import { confirmShellForegroundProcess } from '../../providers/agent-foreground-process' +import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' + +/** Fresh execution-host proof that the spawned shell owns the PTY foreground: + * a post-request process inspection (POSIX `ps`, Windows job membership), + * never cached state. */ +export async function confirmPtyShellForeground(args: { + process: pty.IPty + shellPath: string + isDead: () => boolean +}): Promise { + if (args.isDead() || !args.process.pid) { + return false + } + const confirmed = await confirmShellForegroundProcess( + args.process.pid, + args.shellPath, + process.platform === 'win32' + ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(args.process) } + : {} + ) + return !args.isDead() && confirmed +} diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts index a56d0fc3ae2..e2abd59c6ea 100644 --- a/src/main/daemon/pty-subprocess/subprocess-handle.ts +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -71,6 +71,7 @@ export function createDaemonPtySubprocessHandle(args: { : {}), getForegroundProcess: foreground.getForegroundProcess, confirmForegroundProcess: foreground.confirmForegroundProcess, + confirmShellForeground: foreground.confirmShellForeground, write: (data) => { if (dead) { return diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts new file mode 100644 index 00000000000..c249e4d1d31 --- /dev/null +++ b/src/main/daemon/session-output-pipeline.ts @@ -0,0 +1,33 @@ +import { SessionOutputPlane } from './session-output-plane' +import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { SubprocessHandle } from './session-subprocess-handle' + +/** The session's ordered output pipeline: the recovery barrier feeding the + * output plane. Built together because the barrier's owner is what the + * plane's snapshots publish, and the plane's emit is the barrier's sink. */ +export function createSessionOutputPipeline(opts: { + cols: number + rows: number + scrollback?: number | undefined + wslDistro?: string | undefined + historySeedChunks?: readonly string[] | undefined + subprocess: SubprocessHandle + isAlive: () => boolean +}): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { + let barrier: TerminalShellRecoveryBarrier | null = null + const output = new SessionOutputPlane({ + cols: opts.cols, + rows: opts.rows, + scrollback: opts.scrollback, + wslDistro: opts.wslDistro, + historySeedChunks: opts.historySeedChunks, + getTerminalOwner: () => barrier?.getOwner() + }) + const recoveryBarrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: async () => (await opts.subprocess.confirmShellForeground?.()) ?? false, + release: (emission) => output.emit(emission), + isAlive: opts.isAlive + }) + barrier = recoveryBarrier + return { output, recoveryBarrier } +} diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index 408e95545b8..507ca01a927 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -4,6 +4,9 @@ import { StartupDeviceAttributesQueryFilter } from './startup-device-attributes- import { normalizePtySize } from './daemon-pty-size' import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types' +import type { TerminalOwner } from '../../shared/terminal-owner' +import type { SubprocessHandle } from './session-subprocess-handle' +import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' // Why: bounds in-memory pending output when no client drains it; past the cap we drop records and flag // overflow so the next take falls back to one full snapshot. UTF-16 units; worst-case wire is ~6x, under NDJSON_MAX_LINE_BYTES (16MB). @@ -21,6 +24,9 @@ export type SessionOutputPlaneOptions = { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined + /** Read from the recovery barrier at snapshot time; the barrier scans bytes + * before this plane receives them, so its owner never lags the emulator. */ + getTerminalOwner?: (() => TerminalOwner | undefined) | undefined } /** Everything downstream of the PTY: the scrollback emulator, the pending-output record buffer that @@ -28,6 +34,7 @@ export type SessionOutputPlaneOptions = { export class SessionOutputPlane { readonly historySeeded: boolean | undefined private readonly emulator: HeadlessEmulator + private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined private attachedClients: AttachedClient[] = [] private pendingOutputRecords: PendingOutputRecord[] = [] private pendingOutputBytes = 0 @@ -56,6 +63,7 @@ export class SessionOutputPlane { opts.historySeedChunks === undefined ? undefined : opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk)) + this.readTerminalOwner = opts.getTerminalOwner } get responderParser(): HeadlessEmulator['responderParser'] { @@ -103,9 +111,18 @@ export class SessionOutputPlane { this.record({ kind: 'resize', cols, rows }) } - clearScrollback(): void { + clearScrollback(subprocess: SubprocessHandle, isGatingWrites: boolean): void { + if (this.disposed) { + return + } this.emulator.clearScrollback() this.record({ kind: 'clear' }) + subprocess.clear?.() + nudgePowerShellPromptRepaint({ + subprocess, + isGatingWrites, + isCursorOnEmptyPromptLine: () => this.isCursorOnEmptyPromptLine() + }) } isCursorOnEmptyPromptLine(): boolean { @@ -116,11 +133,29 @@ export class SessionOutputPlane { return this.emulator.getCwd() } + getTerminalOwner(): TerminalOwner | undefined { + return this.readTerminalOwner?.() + } + + /** FIFO fence over the emulator's async parse queue: resolves once every + * write issued before this call has been parsed into the model. */ + flushParsedWrites(): Promise { + if (this.disposed) { + return Promise.resolve() + } + return this.emulator.write('') + } + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { if (this.disposed) { return null } - return { ...this.emulator.getSnapshot(opts), outputSequence: this._outputSequence } + const terminalOwner = this.readTerminalOwner?.() + return { + ...this.emulator.getSnapshot(opts), + ...(terminalOwner ? { terminalOwner } : {}), + outputSequence: this._outputSequence + } } getPartialEscapeTailAnsi(): string { diff --git a/src/main/daemon/session-shell-recovery.test.ts b/src/main/daemon/session-shell-recovery.test.ts new file mode 100644 index 00000000000..7a5ae13663c --- /dev/null +++ b/src/main/daemon/session-shell-recovery.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest' +import { Session } from './session' +import type { SubprocessHandle } from './session-subprocess-handle' + +function createSubprocess() { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const confirmShellForeground = vi.fn( + () => new Promise((resolve) => void (resolveConfirm = resolve)) + ) + const handle = { + pid: 999, + getForegroundProcess: () => null, + confirmShellForeground, + write: () => {}, + resize: () => {}, + pause: () => {}, + resume: () => {}, + kill: () => {}, + forceKill: () => {}, + signal: () => {}, + terminateOwnedTree: () => 'unavailable' as const, + onData(cb: (data: string) => void) { + onData = cb + }, + onExit(cb: (code: number) => void) { + onExit = cb + }, + dispose: () => {} + } as unknown as SubprocessHandle + return { + handle, + confirmShellForeground, + emit: (data: string) => onData?.(data), + exit: (code: number) => onExit?.(code), + confirm: (v: boolean) => resolveConfirm?.(v) + } +} + +describe('Session shell-owned recovery through the output barrier', () => { + it('delivers post-kill shell output into the normalized snapshot instead of the discarded alt buffer', async () => { + const sub = createSubprocess() + const session = new Session({ + sessionId: 's1', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false + } as never) + + sub.emit('before-tui\r\n\x1b[?1049hTUI-FRAME\x1b]133;D;137\x07SHELL-PROMPT-MARKER') + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalledTimes(1)) + sub.confirm(true) + await session.settleShellOwnershipConfirmation() + await new Promise((r) => setTimeout(r, 20)) + + const snap = session.getSnapshot()! + const content = (snap.scrollbackAnsi ?? '') + snap.snapshotAnsi + expect(snap.terminalOwner).toBe('shell') + expect(snap.modes.alternateScreen).toBe(false) + expect(content).toContain('SHELL-PROMPT-MARKER') + expect(content).toContain('before-tui') + session.dispose() + }) + + it('keeps split escapes intact and revokes stale proof when a snapshot lands mid-sequence', async () => { + const sub = createSubprocess() + const session = new Session({ + sessionId: 's2', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false + } as never) + + sub.emit('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalledTimes(1)) + sub.confirm(true) + await session.settleShellOwnershipConfirmation() + await new Promise((r) => setTimeout(r, 20)) + expect(session.getSnapshot()?.terminalOwner).toBe('shell') + + // Live stream continues with a split mode-enable; snapshot lands inside the window. + sub.emit('marker\x1b[?10') + await new Promise((r) => setTimeout(r, 10)) + const mid = session.getSnapshot()! + expect(mid.pendingEscapeTailAnsi).toBe('\x1b[?10') + + sub.emit('49h') + await new Promise((r) => setTimeout(r, 20)) + const after = session.getSnapshot()! + expect(after.modes.alternateScreen).toBe(true) + expect(after.terminalOwner).toBeUndefined() + expect(after.snapshotAnsi).not.toContain('49h') + session.dispose() + }) + + it('flushes queued bytes to clients when the shell exits mid-proof instead of dropping them', async () => { + const sub = createSubprocess() + // Models TerminalHost.reapSession: exit and disposal are one synchronous chain. + const session: Session = new Session({ + sessionId: 's3', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false, + onExit: () => session.dispose() + } as never) + const received: string[] = [] + const exits: number[] = [] + session.attachClient({ + onData: (data: string) => received.push(data), + onExit: (code: number) => exits.push(code) + }) + + sub.emit('\x1b[?1049hTUI\x1b]133;D;137\x07\r\nprompt-after-death') + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalledTimes(1)) + // The shell exits before the proof settles — sub-millisecond in production, + // so it essentially always wins the race against the ps fork. + sub.exit(0) + + expect(received.join('')).toContain('prompt-after-death') + expect(exits).toEqual([0]) + // No injection either: an unproven episode flushes unmodified. + expect(received.join('')).not.toContain('\x1b[?1049l') + }) + + it('flushes nested episodes stacked inside one proof window when the shell exits', async () => { + const sub = createSubprocess() + const session: Session = new Session({ + sessionId: 's4', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false, + onExit: () => session.dispose() + } as never) + const received: string[] = [] + session.attachClient({ onData: (data: string) => received.push(data), onExit: () => {} }) + + // Three alt-screen death cycles inside one proof window: each flush pass can + // re-enter pending on the next trigger, so a single-level flush loses the tail. + sub.emit( + '\x1b[?1049hT1\x1b]133;D;1\x07AAA\x1b[?1049hT2\x1b]133;D;2\x07BBB\x1b[?1049hT3\x1b]133;D;3\x07CCC' + ) + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalled()) + sub.exit(0) + + const joined = received.join('') + for (const marker of ['AAA', 'BBB', 'CCC']) { + expect(joined).toContain(marker) + } + expect(joined).not.toContain('\x1b[?1049l') + }) +}) diff --git a/src/main/daemon/session-subprocess-handle.ts b/src/main/daemon/session-subprocess-handle.ts index 42f2bd43dee..f14469afbb4 100644 --- a/src/main/daemon/session-subprocess-handle.ts +++ b/src/main/daemon/session-subprocess-handle.ts @@ -9,6 +9,8 @@ export type SubprocessHandle = { getForegroundProcess(): string | null /** Await process-table evidence captured after this confirmation request. */ confirmForegroundProcess?(): Promise + /** Proves a fresh post-boundary PTY process tree contains only the shell. */ + confirmShellForeground?(): Promise /** True when shell launch args already delivered the startup command, so the host skips its stdin fallback write. */ startupCommandDeliveredInShellArgs?: boolean /** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index ea37c9237e5..191b8ee7eaa 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -41,6 +41,7 @@ function createMockSubprocess() { getForegroundProcess(): string | null { return this.foregroundProcess }, + confirmShellForeground: vi.fn(async () => true), write(data: string) { written.push(data) }, @@ -116,6 +117,7 @@ describe('Session', () => { ownerBackend?: 'posix-pty' | 'windows-conpty' | 'windows-wsl' wslDistro?: string reportReadinessEvent?: (event: string, details: Record) => void + historySeedChunks?: readonly string[] }): Session { session = new Session({ sessionId: 'test-session', @@ -125,6 +127,7 @@ describe('Session', () => { ...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}), wslDistro: opts?.wslDistro, subprocess, + historySeedChunks: opts?.historySeedChunks, ...(opts?.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), shellReadySupported: opts?.shellReadySupported ?? false, ...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}), @@ -163,6 +166,53 @@ describe('Session', () => { }) describe('data flow', () => { + it('does not confirm shell ownership from historical replay bytes', () => { + createSession({ + historySeedChunks: ['\x1b[?1049hOLD-TUI\x1b]133;D;137\x07old-shell-marker'] + }) + + expect(subprocess.confirmShellForeground).not.toHaveBeenCalled() + expect(session.getSnapshot()?.terminalOwner).toBeUndefined() + }) + + it('answers concurrent runtime confirmations from one episode inspection', async () => { + let resolveConfirmation: ((confirmed: boolean) => void) | undefined + subprocess.confirmShellForeground.mockImplementation( + () => new Promise((resolve) => void (resolveConfirmation = resolve)) + ) + createSession() + + // Why no inspection without a candidate: the RPC reads the barrier's + // settled verdict; it must never mint proof the byte stream didn't ask for. + await expect(session.confirmShellForeground()).resolves.toBe(false) + expect(subprocess.confirmShellForeground).not.toHaveBeenCalled() + + subprocess.simulateData('\x1b[?1049hTUI\x1b]133;D;137\x07') + const first = session.confirmShellForeground() + const second = session.confirmShellForeground() + expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + resolveConfirmation?.(true) + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + }) + + it('reuses the parser confirmation for a concurrent runtime request', async () => { + let resolveConfirmation: ((confirmed: boolean) => void) | undefined + subprocess.confirmShellForeground.mockImplementation( + () => new Promise((resolve) => void (resolveConfirmation = resolve)) + ) + createSession() + + subprocess.simulateData('\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker') + const runtimeConfirmation = session.confirmShellForeground() + expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + resolveConfirmation?.(true) + + await expect(runtimeConfirmation).resolves.toBe(true) + await vi.waitFor(() => expect(session.getSnapshot()?.terminalOwner).toBe('shell')) + }) + it('forwards subprocess data to attached clients', () => { createSession() const received: string[] = [] diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 2862a01e6f1..b0f1dfa538d 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -1,12 +1,13 @@ import { isValidPtySize } from './daemon-pty-size' -import { SessionOutputPlane, type AttachedClient } from './session-output-plane' +import type { SessionOutputPlane, AttachedClient } from './session-output-plane' +import { createSessionOutputPipeline } from './session-output-pipeline' import { SessionProducerPause } from './session-producer-pause' import { SessionShellReadyBarrier } from './session-shell-ready-barrier' +import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import { SessionTerminationController, IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS } from './session-termination-controller' -import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' import type { SubprocessHandle } from './session-subprocess-handle' import type { JobTerminationOutcome } from '../windows/windows-pty-job' import type { SessionOptions } from './session-options' @@ -38,6 +39,7 @@ export class Session { private readonly shellReady: SessionShellReadyBarrier private readonly termination: SessionTerminationController private readonly startupIngress: PtyStartupIngress + private readonly recoveryBarrier: TerminalShellRecoveryBarrier constructor(opts: SessionOptions) { this.sessionId = opts.sessionId @@ -46,13 +48,17 @@ export class Session { this.wslDistro = opts.wslDistro ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit - this.output = new SessionOutputPlane({ + const pipeline = createSessionOutputPipeline({ cols: opts.cols, rows: opts.rows, scrollback: opts.scrollback, wslDistro: opts.wslDistro, - historySeedChunks: opts.historySeedChunks + historySeedChunks: opts.historySeedChunks, + subprocess: this.subprocess, + isAlive: () => !this._disposed && this._state !== 'exited' }) + this.output = pipeline.output + this.recoveryBarrier = pipeline.recoveryBarrier this.producerPause = new SessionProducerPause(this.subprocess) this.termination = new SessionTerminationController({ sessionId: this.sessionId, @@ -78,10 +84,14 @@ export class Session { ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), ...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), write: (data) => this.subprocess.write(data), - onEmission: (emission) => this.output.emit(emission) + onEmission: (emission) => this.recoveryBarrier.accept(emission) }) this.shellReady.startPromptReadinessProbe() - this.subprocess.onData((data) => this.handleSubprocessData(data)) + this.subprocess.onData((data) => { + if (!this._disposed) { + this.shellReady.ingestSubprocessData(data) + } + }) this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) } @@ -150,10 +160,7 @@ export class Session { } resize(cols: number, rows: number): void { - if (this._state === 'exited' || this._disposed) { - return - } - if (!isValidPtySize(cols, rows)) { + if (this._state === 'exited' || this._disposed || !isValidPtySize(cols, rows)) { return } this.output.resize(cols, rows) @@ -253,22 +260,26 @@ export class Session { return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess() } + confirmShellForeground(): Promise { + return this.recoveryBarrier.confirmOwnerSettled() + } + + async settleShellOwnershipConfirmation(): Promise { + await this.recoveryBarrier.awaitProofSettled() + // Why the fence: a snapshot at settle-resolution must not race the drained prompt's async parse. + await this.output.flushParsedWrites() + } + clearScrollback(): void { - if (this._disposed) { - return - } - this.output.clearScrollback() - this.subprocess.clear?.() - nudgePowerShellPromptRepaint({ - subprocess: this.subprocess, - isGatingWrites: this.shellReady.isGatingWrites, - isCursorOnEmptyPromptLine: () => this.output.isCursorOnEmptyPromptLine() - }) + this.output.clearScrollback(this.subprocess, this.shellReady.isGatingWrites) } prepareForFinalSnapshot(): string { const held = this.shellReady.releaseHeldBytes() this.startupIngress.snapshotBarrier() + // Why last: snapshotBarrier can emit held spans into the barrier, and a + // teardown checkpoint mid-episode must not lose the barrier's queued bytes. + this.recoveryBarrier.flushPending() return held } @@ -282,6 +293,10 @@ export class Session { this.shellReady.releaseDeviceAttributes() this.shellReady.releaseHeldBytes() this.startupIngress.drainAndClose() + // Why after drainAndClose (and before clearClients below): a dispose + // mid-episode must deliver the barrier's queued bytes — drained ingress + // included — while clients are attached and the emulator accepts writes. + this.recoveryBarrier.flushPending() const wasTerminating = this.termination.isTerminating && this._state !== 'exited' const clientsToNotify = wasTerminating ? this.output.snapshotClients() : [] if (wasTerminating) { @@ -299,6 +314,7 @@ export class Session { this.output.clearClients() this.shellReady.clearPendingWrites() + this.recoveryBarrier.dispose() this.output.disposeEmulator() for (const client of clientsToNotify) { @@ -338,13 +354,6 @@ export class Session { this.termination.disposeSubprocessHandle() } - private handleSubprocessData(data: string): void { - if (this._disposed) { - return - } - this.shellReady.ingestSubprocessData(data) - } - private handleSubprocessExit(code: number, cause?: TerminalExitCause): void { this.termination.markPhysicalExit() if (this._disposed) { @@ -355,6 +364,11 @@ export class Session { this.shellReady.disposePromptReadinessProbe() this.shellReady.releaseHeldBytes() this.startupIngress.drainAndClose() + // Why after drainAndClose: drained ingress bytes re-enter the barrier and can + // open a fresh episode; flushing here delivers them too. A shell exiting + // mid-proof must not strand the queued post-133;D prompt — those bytes belong + // to clients, records, and history before broadcastExit below. + this.recoveryBarrier.flushPending() this._exitCode = code this._state = 'exited' this.termination.clearTerminating() diff --git a/src/main/daemon/terminal-checkpoint-serializer.test.ts b/src/main/daemon/terminal-checkpoint-serializer.test.ts index 00be8f39eee..b53e207aa52 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.test.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.test.ts @@ -121,6 +121,25 @@ describe('terminal checkpoint serializer', () => { } }) + it('preserves shell ownership when an oversized alternate-screen checkpoint is trimmed', async () => { + const input = snapshot({ + snapshotAnsi: `\x1b[?1049h${'row\r\n'.repeat(500)}visible`, + rehydrateSequences: '\x1b[?1049h', + terminalOwner: 'shell', + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: true + }, + scrollbackLines: 500 + }) + + const serialized = await serializeTerminalCheckpointWithinLimit(input, metadata, 2_048) + + expect(JSON.parse(serialized)).toMatchObject({ terminalOwner: 'shell' }) + }) + it('rejects an oversized escaped candidate without materializing it', async () => { const oversized = String.fromCharCode(0).repeat(100_000) const stringify = vi.spyOn(JSON, 'stringify') diff --git a/src/main/daemon/terminal-checkpoint-serializer.ts b/src/main/daemon/terminal-checkpoint-serializer.ts index bf72f42cb70..8776fb55588 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.ts @@ -27,6 +27,7 @@ function checkpointFile( modes: snapshot.modes, scrollbackLines: snapshot.scrollbackLines, ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}), + ...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {}), generation: metadata.generation, ...(metadata.pendingOutputSeq !== undefined ? { pendingOutputSeq: metadata.pendingOutputSeq } @@ -260,7 +261,10 @@ export async function serializeTerminalCheckpointWithinLimit( const emulator = await replaySnapshot(snapshot) try { - const visibleOnly = emulator.getSnapshot({ scrollbackRows: 0 }) + // Why carried, not re-derived: trimming rows cannot change who owned the + // terminal at this checkpoint's boundary. + const ownership = snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {} + const visibleOnly = { ...emulator.getSnapshot({ scrollbackRows: 0 }), ...ownership } let bestJson = stringifyWithinLimit(checkpointFile(visibleOnly, metadata), maxBytes) if (bestJson === null) { throw new Error('Terminal checkpoint metadata exceeds byte limit') @@ -270,7 +274,7 @@ export async function serializeTerminalCheckpointWithinLimit( let high = visibleOnly.scrollbackLines while (low <= high) { const rows = low + Math.floor((high - low) / 2) - const candidate = emulator.getSnapshot({ scrollbackRows: rows }) + const candidate = { ...emulator.getSnapshot({ scrollbackRows: rows }), ...ownership } const candidateJson = stringifyWithinLimit(checkpointFile(candidate, metadata), maxBytes) if (candidateJson === null) { high = rows - 1 diff --git a/src/main/daemon/terminal-history-checkpoint-reader.ts b/src/main/daemon/terminal-history-checkpoint-reader.ts index 3132290a512..13007fb60af 100644 --- a/src/main/daemon/terminal-history-checkpoint-reader.ts +++ b/src/main/daemon/terminal-history-checkpoint-reader.ts @@ -4,6 +4,7 @@ import { readTerminalHistoryJsonAsync } from './terminal-history-file-reader' import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits' import { isValidTerminalHistorySize } from './terminal-history-dimensions' import type { TerminalCheckpointFile, TerminalModes } from './types' +import { parseTerminalOwner } from '../../shared/terminal-owner' export type TerminalHistoryCheckpointRead = | { status: 'missing' } @@ -49,7 +50,9 @@ function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFi (checkpoint.oscLinks === undefined || isTerminalOscLinkRanges(checkpoint.oscLinks)) && (checkpoint.pendingEscapeTailAnsi === undefined || typeof checkpoint.pendingEscapeTailAnsi === 'string') && - (checkpoint.lastTitle === undefined || typeof checkpoint.lastTitle === 'string') + (checkpoint.lastTitle === undefined || typeof checkpoint.lastTitle === 'string') && + (checkpoint.terminalOwner === undefined || + parseTerminalOwner(checkpoint.terminalOwner) !== undefined) ) } diff --git a/src/main/daemon/terminal-history-cold-restore-info.ts b/src/main/daemon/terminal-history-cold-restore-info.ts index 71cbf1102ed..95b92d1cbdd 100644 --- a/src/main/daemon/terminal-history-cold-restore-info.ts +++ b/src/main/daemon/terminal-history-cold-restore-info.ts @@ -1,6 +1,7 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { SessionMeta } from './terminal-history-metadata' import type { TerminalModes } from './types' +import type { TerminalOwner } from '../../shared/terminal-owner' export type ColdRestoreInfo = { snapshotAnsi: string @@ -15,6 +16,7 @@ export type ColdRestoreInfo = { modes: TerminalModes pendingEscapeTailAnsi?: string lastTitle?: string + terminalOwner?: TerminalOwner } type RestoredSnapshot = { @@ -29,6 +31,7 @@ type RestoredSnapshot = { modes: TerminalModes pendingEscapeTailAnsi?: string lastTitle?: string + terminalOwner?: TerminalOwner } export function coldRestoreInfoFromSnapshot( @@ -53,6 +56,7 @@ export function coldRestoreInfoFromSnapshot( ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), - ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}) + ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}), + ...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {}) } } diff --git a/src/main/daemon/terminal-host-concurrent-create.test.ts b/src/main/daemon/terminal-host-concurrent-create.test.ts index 8e404147e76..aa7b9fda956 100644 --- a/src/main/daemon/terminal-host-concurrent-create.test.ts +++ b/src/main/daemon/terminal-host-concurrent-create.test.ts @@ -4,22 +4,42 @@ import { TerminalHost } from './terminal-host' vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) -function mockSubprocess(): SubprocessHandle { +type MockSubprocess = SubprocessHandle & { + emitData: (data: string) => void + emitExit: (code: number) => void +} + +function mockSubprocess( + options: { confirmShellForeground?: () => Promise } = {} +): MockSubprocess { + let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null return { pid: 4242, getForegroundProcess: vi.fn(() => null), + ...(options.confirmShellForeground + ? { confirmShellForeground: options.confirmShellForeground } + : {}), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 1)), forceKill: vi.fn(() => onExitCb?.(137)), signal: vi.fn(), - onData: vi.fn(), + terminateOwnedTree: () => 'unavailable' as const, + onData(cb) { + onDataCb = cb + }, onExit(cb) { onExitCb = cb }, - dispose: vi.fn() - } as unknown as SubprocessHandle + dispose: vi.fn(), + emitData(data) { + onDataCb?.(data) + }, + emitExit(code) { + onExitCb?.(code) + } + } as MockSubprocess } const streamClient = { onData: vi.fn(), onExit: vi.fn() } @@ -53,6 +73,28 @@ describe('concurrent createOrAttach across the async spawn', () => { await host.dispose() }) + it('attaches immediately while an ownership proof is still in flight', async () => { + // The proof never resolves: attach must not wait on it. The viewer takes + // the pre-reset snapshot and the in-stream reset heals its first frame. + const first = mockSubprocess({ + confirmShellForeground: vi.fn(() => new Promise(() => {})) + }) + const spawnSubprocess = vi + .fn<() => Promise>() + .mockResolvedValueOnce(first) + .mockImplementation(async () => mockSubprocess()) + const host = new TerminalHost({ spawnSubprocess }) + await host.createOrAttach(createOptions('settle-free')) + first.emitData('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(first.confirmShellForeground).toHaveBeenCalledOnce()) + + await expect(host.createOrAttach(createOptions('settle-free'))).resolves.toMatchObject({ + isNew: false + }) + expect(spawnSubprocess).toHaveBeenCalledOnce() + await host.dispose() + }) + it('lets the next caller spawn after a failed spawn releases the gate', async () => { const spawnSubprocess = vi .fn<() => Promise>() diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 1cb2a75f232..24f6cc2e867 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -38,6 +38,12 @@ export async function createOrAttachTerminalSession( throw new SessionNotFoundError(opts.sessionId) } + // Why no ownership settle here: attach is synchronous by contract. A viewer + // connecting inside an in-flight recovery proof (~100ms) gets the pre-reset + // snapshot and the injected reset arrives in-order over its stream — a + // self-healing first frame. Waiting instead created a race window (cancel, + // exit, kill during the await) that produced repeated regressions. Checkpoint + // readers that need a settled owner use getSettledSnapshot. if (existing && existing.isAlive && !existing.isTerminating) { const snapshot = existing.getSnapshot() existing.detachAllClients() diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index c8a57715b91..f85980b5453 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -233,6 +233,17 @@ export class TerminalHost { return session.confirmForegroundProcess() } + async confirmShellForeground(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (session?.isAlive !== true) { + return false + } + const confirmed = await session.confirmShellForeground() + // Why the recheck: proof for a session that exited or was replaced during + // the await is stale; the caller would bind it to the successor's stream. + return confirmed && this.sessions.get(sessionId) === session && session.isAlive + } + clearScrollback(sessionId: string): void { this.getAliveSession(sessionId).clearScrollback() } @@ -246,6 +257,21 @@ export class TerminalHost { return session.getSnapshot(opts) } + async getSettledSnapshot( + sessionId: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return null + } + await session.settleShellOwnershipConfirmation() + // Why no liveness recheck: the sync path returned the pre-exit snapshot when + // a session died a beat after the call; a disposal during the settle yields + // null naturally from the plane's own guard. + return session.getSnapshot(opts) + } + // Why: scan-authority handoff seed (null-not-throw like getSnapshot) — emulator's dangling incomplete escape at the stream position. getPartialEscapeTailAnsi(sessionId: string): string { const session = this.sessions.get(sessionId) diff --git a/src/main/daemon/terminal-shell-clean-exit-confirmation.ts b/src/main/daemon/terminal-shell-clean-exit-confirmation.ts new file mode 100644 index 00000000000..11b0fc76bfc --- /dev/null +++ b/src/main/daemon/terminal-shell-clean-exit-confirmation.ts @@ -0,0 +1,100 @@ +import type { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' + +/** + * Async ownership proof for clean alternate-screen exits: no pause, no + * injection — a confirmed proof only marks the scanner's owner, guarded by the + * generation captured at the candidate. Every attempt retires at the deadline + * so a proof that never settles cannot wedge later candidates, and a retired + * attempt's late verdict is inert. + */ +export class TerminalShellCleanExitConfirmation { + private inFlightState = false + private attempt = 0 + private waiters: (() => void)[] = [] + private latestCandidateGeneration: number | undefined + + constructor( + private readonly opts: { + scanner: TerminalShellLifecycleScanner + confirmShellForeground: () => Promise + deadlineMs: () => number + isDisposed: () => boolean + isAlive: () => boolean + } + ) {} + + get inFlight(): boolean { + return this.inFlightState + } + + waitSettled(): Promise { + return new Promise((resolve) => this.waiters.push(resolve)) + } + + drainWaiters(): void { + const waiters = this.waiters + this.waiters = [] + for (const waiter of waiters) { + waiter() + } + } + + start(generation: number): void { + this.latestCandidateGeneration = generation + if (this.inFlightState || this.opts.isDisposed()) { + return + } + this.inFlightState = true + const requested = generation + const attempt = ++this.attempt + // Why the deadline: a proof that never settles must not wedge the in-flight + // flag for the session's life, silently ignoring every later candidate. + const deadline = setTimeout(() => this.retire(attempt, requested), this.opts.deadlineMs()) + deadline.unref?.() + // Why the guard: the callback is injected; a synchronous throw must not + // strand the in-flight flag. + let proof: Promise + try { + proof = this.opts.confirmShellForeground() + } catch { + proof = Promise.resolve(false) + } + void proof + .then((confirmed) => { + // A retired attempt's late verdict is inert: an unsettled proof already + // read as no owner, and flipping it afterwards would race fresh bytes. + if ( + attempt === this.attempt && + confirmed && + !this.opts.isDisposed() && + this.opts.isAlive() + ) { + this.opts.scanner.trySetOwner(requested) + } + }) + .catch(() => {}) + .finally(() => { + clearTimeout(deadline) + this.retire(attempt, requested) + }) + } + + private retire(attempt: number, requested: number): void { + if (attempt !== this.attempt) { + return + } + this.attempt += 1 + this.inFlightState = false + const latest = this.latestCandidateGeneration + if ( + latest !== undefined && + latest !== requested && + latest === this.opts.scanner.generation && + !this.opts.isDisposed() + ) { + // One superseding candidate can still be current; stale ones are not retried. + this.start(latest) + } + this.drainWaiters() + } +} diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts new file mode 100644 index 00000000000..6d5fa662187 --- /dev/null +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest' +import { POST_REPLAY_DEAD_TUI_RESET } from '../../shared/terminal-mode-reset-profiles' +import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' + +const ENTER_ALT = '\x1b[?1049h' +const LEAVE_ALT = '\x1b[?1049l' + +describe('TerminalShellLifecycleScanner', () => { + describe('unclean death trigger', () => { + it('stops at the OSC 133;D terminator while the alternate screen is up', () => { + const scanner = new TerminalShellLifecycleScanner() + const chunk = `${ENTER_ALT}TUI\x1b]133;D;137\x07shell-marker` + + const events = scanner.scan(chunk) + + expect(events.uncleanDeathTriggerEnd).toBe(chunk.indexOf('shell-marker')) + expect(chunk.slice(events.uncleanDeathTriggerEnd)).toBe('shell-marker') + expect(scanner.isAlternateScreenActive).toBe(true) + expect(scanner.owner).toBeUndefined() + expect(events.cleanExitCandidate).toBeUndefined() + }) + + it('does not consume bytes past the trigger', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x07shell-marker`) + + expect(scanner.scan('shell-marker')).toEqual({}) + }) + + it('carries no partial sequence out of the trigger', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x07\x1b[?10`) + const generation = scanner.generation + + // A leaked tail would compose with this into `\x1b[?1049h` and revoke again. + expect(scanner.scan('49h')).toEqual({}) + expect(scanner.generation).toBe(generation) + }) + + it('reports a chunk-local index when the sequence spans three chunks', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan('\x1b[?10')).toEqual({}) + expect(scanner.scan('49hTUI\x1b]133;D;1')).toEqual({}) + const events = scanner.scan('37\x07tail') + + expect(events.uncleanDeathTriggerEnd).toBe('37\x07'.length) + expect('37\x07tail'.slice(events.uncleanDeathTriggerEnd)).toBe('tail') + expect(scanner.owner).toBeUndefined() + }) + + it('accepts an ST-terminated OSC', () => { + const scanner = new TerminalShellLifecycleScanner() + const chunk = `${ENTER_ALT}TUI\x1b]133;D;137\x1b\\rest` + + const events = scanner.scan(chunk) + + expect(events.uncleanDeathTriggerEnd).toBe(chunk.indexOf('rest')) + }) + + it('accepts an ST split across chunks', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x1b`)).toEqual({}) + const events = scanner.scan('\\rest') + + expect(events.uncleanDeathTriggerEnd).toBe(1) + }) + + it('fires again on the re-fed remainder', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x07REMAINDER`) + + // The remainder starts a fresh TUI that dies the same way. + expect(scanner.scan(LEAVE_ALT)).toEqual({}) + const again = scanner.scan(`${ENTER_ALT}AGAIN\x1b]133;D;9\x07`) + + expect(again.uncleanDeathTriggerEnd).toBe(`${ENTER_ALT}AGAIN\x1b]133;D;9\x07`.length) + }) + }) + + describe('clean exit candidates', () => { + it('offers ownership after a TUI leaves the alternate screen and the command ends', () => { + const scanner = new TerminalShellLifecycleScanner() + + const events = scanner.scan(`${ENTER_ALT}TUI${LEAVE_ALT}\x1b]133;D;0\x07`) + + expect(events.uncleanDeathTriggerEnd).toBeUndefined() + const candidate = events.cleanExitCandidate + expect(candidate).toBeDefined() + expect(scanner.owner).toBeUndefined() + expect(scanner.trySetOwner(candidate!.generation)).toBe(true) + expect(scanner.owner).toBe('shell') + expect(scanner.isAlternateScreenActive).toBe(false) + }) + + it('invalidates the candidate when output arrives before the owner is set', () => { + const scanner = new TerminalShellLifecycleScanner() + const candidate = scanner.scan( + `${ENTER_ALT}TUI${LEAVE_ALT}\x1b]133;D;0\x07` + ).cleanExitCandidate + + scanner.scan('\x1b]133;C\x07') + + expect(scanner.trySetOwner(candidate!.generation)).toBe(false) + expect(scanner.owner).toBeUndefined() + }) + + it('does not let a candidate survive a TUI started later in the same chunk', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI${LEAVE_ALT}`) + + const candidate = scanner.scan(`\x1b]133;D;0\x07${ENTER_ALT}LIVE`).cleanExitCandidate + + expect(candidate).toBeDefined() + expect(scanner.trySetOwner(candidate!.generation)).toBe(false) + expect(scanner.owner).toBeUndefined() + }) + + it('emits nothing for a command that never entered the alternate screen', () => { + const scanner = new TerminalShellLifecycleScanner() + + const events = scanner.scan(`\x1b]133;D;0\x07${ENTER_ALT}LIVE`) + + expect(events).toEqual({}) + }) + + it('forgets the alternate-screen visit once a new command starts', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI${LEAVE_ALT}`) + + scanner.scan('\x1b]133;C\x07') + + expect(scanner.scan('\x1b]133;D;0\x07')).toEqual({}) + }) + + it('emits no candidate for an ordinary command completion', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan('\x1b]133;D;0\x07')).toEqual({}) + }) + + it('keeps only the last candidate of a chunk', () => { + const scanner = new TerminalShellLifecycleScanner() + + const events = scanner.scan( + `${ENTER_ALT}a${LEAVE_ALT}\x1b]133;D;0\x07${ENTER_ALT}b${LEAVE_ALT}\x1b]133;D;0\x07` + ) + + expect(events.cleanExitCandidate?.generation).toBe(scanner.generation) + expect(scanner.trySetOwner(events.cleanExitCandidate!.generation)).toBe(true) + }) + }) + + describe('revocation', () => { + it('revokes on a mouse-only DECSET', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + + scanner.scan('\x1b[?1003h') + + expect(scanner.owner).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(false) + }) + + it('revokes on a kitty keyboard push', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + + scanner.scan('\x1b[>1u') + + expect(scanner.owner).toBeUndefined() + }) + + it('ignores the kitty pop and clear forms we inject ourselves', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan('\x1b[<99u\x1b[=0u') + + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + }) + + it('revokes on every command boundary, not just ones touching the alternate screen', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan('\x1b]133;D;0\x07') + + expect(scanner.owner).toBeUndefined() + expect(scanner.generation).toBe(generation + 1) + + scanner.trySetOwner(scanner.generation) + scanner.scan('\x1b]133;C\x07') + + expect(scanner.owner).toBeUndefined() + }) + + it('revokes and clears alternate-screen state on RIS', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(ENTER_ALT) + scanner.seedOwner('shell') + + scanner.scan('\x1bc') + + expect(scanner.owner).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(false) + // The cleared flag means a later D is a plain completion, not a clean exit. + expect(scanner.scan('\x1b]133;D;0\x07')).toEqual({}) + }) + + it('recognizes the 8-bit CSI introducer', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + + scanner.scan('\x9b?1049h') + + expect(scanner.owner).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(true) + }) + + it('does not revoke on DECRST leaving the alternate screen', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan(LEAVE_ALT) + + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + }) + }) + + describe('inert input', () => { + it('ignores plain and colored output', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + expect(scanner.scan('hello world\r\n')).toEqual({}) + expect(scanner.scan('\x1b[31mred\x1b[0m')).toEqual({}) + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + }) + + it('leaves ownership intact across our own dead-TUI reset', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan(POST_REPLAY_DEAD_TUI_RESET) + + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + expect(scanner.isAlternateScreenActive).toBe(false) + }) + + it('drops an oversized carried tail instead of growing without bound', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan(`\x1b]133;${'x'.repeat(5000)}`)).toEqual({}) + expect(scanner.scan('\x07')).toEqual({}) + }) + }) +}) + +describe('unclean trigger arming', () => { + it('fires once per alternate-screen occupancy and re-arms only on a fresh entry', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan('\x1b[?1049hTUI\x1b]133;D;137\x07').uncleanDeathTriggerEnd).toBeDefined() + // Refuted path: no reset scanned, alt still active — a later prompt's D must not re-trigger. + const second = scanner.scan('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07') + expect(second.uncleanDeathTriggerEnd).toBeUndefined() + expect(second.cleanExitCandidate).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(true) + + expect( + scanner.scan('\x1b]133;C\x07\x1b[?1049hAGAIN\x1b]133;D;9\x07').uncleanDeathTriggerEnd + ).toBeDefined() + }) +}) diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.ts new file mode 100644 index 00000000000..97a4a121dce --- /dev/null +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.ts @@ -0,0 +1,223 @@ +import type { TerminalOwner } from '../../shared/terminal-owner' + +// Why: PTY/SSH chunks can split a long combined DECSET or an OSC 133 payload +// before its terminator. Keep parser state far beyond normal sequence lengths +// while still bounding memory. +const SCAN_TAIL_LIMIT = 4096 + +// Any mode a full-screen or mouse-driven app arms: once one appears the shell +// is no longer the thing writing to this pane. +const TUI_MODE_ENABLES = new Set([47, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 1016, 1047, 1049]) +const ALTERNATE_SCREEN_MODES = new Set([47, 1047, 1049]) + +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const LIFECYCLE_OSC = /\x1b\]133;([^\x07\x1b]*)(?:\x07|\x1b\\)/ +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const PRIVATE_MODE = /\x1b\[\?([0-9;]*)([hl])|\x9b\?([0-9;]*)([hl])/ +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const KITTY_KEYBOARD = /\x1b\[([>=])([0-9;]*)u|\x9b([>=])([0-9;]*)u/ +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const FULL_RESET = /\x1bc/ + +// Groups: 1 OSC 133 payload, 2/3 and 4/5 private-mode params + final, 6/7 and 8/9 kitty prefix + params. +const SEQUENCE_RE = new RegExp( + [LIFECYCLE_OSC, PRIVATE_MODE, KITTY_KEYBOARD, FULL_RESET].map((re) => re.source).join('|'), + 'g' +) + +export type ShellLifecycleScanEvents = { + /** + * Index into the chunk just handed to `scan()`, one past the terminator of an + * OSC 133;D that arrived while the alternate screen was still active. Bytes at + * and after this index were NOT consumed; the caller re-feeds them. + */ + uncleanDeathTriggerEnd?: number + /** An OSC 133;D closed a command that had entered the alternate screen and left it cleanly. */ + cleanExitCandidate?: { generation: number } +} + +/** + * Streaming, chunk-split-safe scanner of raw PTY output that tracks which side + * owns the stream. Callers must feed `scan()` the same bytes the terminal + * parsed, in order. + * + * Ownership is generation-guarded rather than a plain flag: an async consumer + * that decides "the shell owns this pane" from an event must prove no revoking + * sequence slipped in between, which `trySetOwner()` checks. + */ +export class TerminalShellLifecycleScanner { + private scanTail = '' + private ownerState: TerminalOwner | undefined + private generationState = 0 + private altActive = false + private commandEnteredAlternateScreen = false + // Why one-shot: a refuted proof leaves altActive true (no reset was ever + // scanned), and without disarming every later prompt's D would re-open a + // full pause-and-inspect episode. Only a fresh alternate-screen entry re-arms. + private uncleanTriggerArmed = false + + get owner(): TerminalOwner | undefined { + return this.ownerState + } + + get generation(): number { + return this.generationState + } + + get isAlternateScreenActive(): boolean { + return this.altActive + } + + trySetOwner(generation: number): boolean { + if (generation !== this.generationState) { + return false + } + this.ownerState = 'shell' + return true + } + + seedOwner(owner: TerminalOwner | undefined, opts: { alternateScreen?: boolean } = {}): void { + this.generationState += 1 + this.ownerState = owner + if (opts.alternateScreen !== undefined) { + // Why seeded mode state matters: restored bytes bypass scan(), so without + // this a mirror seeded mid-TUI never arms its unclean-death trigger and + // the whole occupancy loses recovery. + this.altActive = opts.alternateScreen + this.uncleanTriggerArmed = opts.alternateScreen + this.commandEnteredAlternateScreen = opts.alternateScreen + } + } + + scan(chunk: string): ShellLifecycleScanEvents { + const events: ShellLifecycleScanEvents = {} + // Why the pre-filter: this runs per chunk for every session; a flood chunk + // with no escape introducer must not pay the regex pass. Split sequences + // stay correct because a partial one always left a non-empty scanTail. + if (this.scanTail.length === 0 && !chunk.includes('\x1b') && !chunk.includes('\x9b')) { + return events + } + const previousTailLength = this.scanTail.length + const input = previousTailLength === 0 ? chunk : this.scanTail + chunk + this.scanTail = this.extractScanTail(input) + // The shared regex is safe because scan() is synchronous and never re-enters. + SEQUENCE_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = SEQUENCE_RE.exec(input)) !== null) { + const oscPayload = match[1] + if (match[0] === '\x1bc') { + this.revoke() + this.altActive = false + this.commandEnteredAlternateScreen = false + this.uncleanTriggerArmed = false + continue + } + if (oscPayload !== undefined) { + const marker = oscPayload[0] + if (marker === 'C') { + this.revoke() + this.commandEnteredAlternateScreen = false + continue + } + if (marker !== 'D') { + continue + } + // An alternate screen still up at command-finished means the app died + // without its own teardown; the caller must repair before more bytes land. + const uncleanDeath = this.altActive && this.uncleanTriggerArmed + const cleanExit = !uncleanDeath && this.commandEnteredAlternateScreen && !this.altActive + this.revoke() + this.commandEnteredAlternateScreen = false + if (uncleanDeath) { + this.uncleanTriggerArmed = false + this.scanTail = '' + // Why the clamp: a complete OSC can never sit wholly inside the + // carried tail (extractScanTail keeps incomplete ones only), so this + // is > 0 by construction — the clamp only guards that invariant + // against a future loosening of the tail extractor. + events.uncleanDeathTriggerEnd = Math.max( + 0, + match.index + match[0].length - previousTailLength + ) + return events + } + if (cleanExit) { + events.cleanExitCandidate = { generation: this.generationState } + } + continue + } + const kittyPrefix = match[6] ?? match[8] + if (kittyPrefix !== undefined) { + // `CSI < n u` (pop) and `CSI = 0 u` (clear) appear in our own injected + // reset, so only a push of real flags counts as a new owner. + const first = Number((match[7] ?? match[9] ?? '').split(';')[0]) + if (Number.isInteger(first) && first > 0) { + this.revoke() + } + continue + } + const enabled = (match[3] ?? match[5]) === 'h' + for (const rawParam of (match[2] ?? match[4] ?? '').split(';')) { + if (rawParam === '') { + continue + } + const param = Number(rawParam) + if (!Number.isInteger(param)) { + continue + } + if (enabled && TUI_MODE_ENABLES.has(param)) { + this.revoke() + } + if (ALTERNATE_SCREEN_MODES.has(param)) { + this.altActive = enabled + if (enabled) { + this.commandEnteredAlternateScreen = true + this.uncleanTriggerArmed = true + } + } + } + } + return events + } + + private revoke(): void { + this.generationState += 1 + this.ownerState = undefined + } + + private extractScanTail(input: string): string { + const oscStart = input.lastIndexOf('\x1b]') + if (oscStart !== -1 && isIncompleteLifecycleOsc(input.slice(oscStart))) { + return boundTail(input.slice(oscStart)) + } + const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) + if (start === -1) { + return '' + } + const tail = boundTail(input.slice(start)) + if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b' || tail === '') { + return tail + } + const params = tail.startsWith('\x1b[') + ? tail.slice(2) + : tail.startsWith('\x9b') + ? tail.slice(1) + : undefined + return params !== undefined && /^[?>=][0-9;]*$/.test(params) ? tail : '' + } +} + +function boundTail(tail: string): string { + return tail.length > SCAN_TAIL_LIMIT ? '' : tail +} + +/** `tail` starts with `\x1b]`; true only while it could still become a complete OSC 133. */ +function isIncompleteLifecycleOsc(tail: string): boolean { + const body = tail.slice(2) + if (body.length < 4) { + return '133;'.startsWith(body) + } + // Payload may end mid-ST: the trailing ESC is the first half of `\x1b\`. + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + return body.startsWith('133;') && /^[^\x07\x1b]*\x1b?$/.test(body.slice(4)) +} diff --git a/src/main/daemon/terminal-shell-recovery-barrier.test.ts b/src/main/daemon/terminal-shell-recovery-barrier.test.ts new file mode 100644 index 00000000000..5c6346e8a67 --- /dev/null +++ b/src/main/daemon/terminal-shell-recovery-barrier.test.ts @@ -0,0 +1,405 @@ +import { describe, expect, it, vi } from 'vitest' +import { POST_REPLAY_DEAD_TUI_RESET } from '../../shared/terminal-mode-reset-profiles' +import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' + +const TRIGGER = '\x1b[?1049hTUI\x1b]133;D;137\x07' + +function passthrough(data: string, rawStartSeq = 0): PtyIngressEmission { + return { data, rawStartSeq, rawEndSeq: rawStartSeq + data.length, transformed: false } +} + +function createBarrier(opts?: { + confirm?: () => Promise + isAlive?: () => boolean + maxQueuedBytes?: number + maxPendingMs?: number +}) { + const released: PtyIngressEmission[] = [] + const confirm = vi.fn(opts?.confirm ?? (async () => true)) + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: confirm, + release: (emission) => released.push(emission), + isAlive: opts?.isAlive ?? (() => true), + ...(opts?.maxQueuedBytes !== undefined ? { maxQueuedBytes: opts.maxQueuedBytes } : {}), + ...(opts?.maxPendingMs !== undefined ? { maxPendingMs: opts.maxPendingMs } : {}) + }) + return { barrier, released, confirm } +} + +describe('TerminalShellRecoveryBarrier', () => { + it('releases ordinary output untouched without inspections', () => { + const { barrier, released, confirm } = createBarrier() + const emission = passthrough('plain \x1b[31mred\x1b[0m output\r\n') + barrier.accept(emission) + + expect(released).toEqual([emission]) + expect(confirm).not.toHaveBeenCalled() + expect(barrier.getOwner()).toBeUndefined() + }) + + it('pauses at the unclean-death boundary and delivers the queued prompt after the injected reset', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released, confirm } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough(`${TRIGGER}SHELL-PROMPT`, 100)) + expect(confirm).toHaveBeenCalledTimes(1) + expect(released).toEqual([ + { data: TRIGGER, rawStartSeq: 100, rawEndSeq: 100 + TRIGGER.length, transformed: false } + ]) + + resolveConfirm?.(true) + await vi.waitFor(() => expect(released).toHaveLength(3)) + expect(released[1]).toEqual({ + data: POST_REPLAY_DEAD_TUI_RESET, + rawStartSeq: 100 + TRIGGER.length, + rawEndSeq: 100 + TRIGGER.length, + transformed: true + }) + expect(released[2]).toEqual({ + data: 'SHELL-PROMPT', + rawStartSeq: 100 + TRIGGER.length, + rawEndSeq: 100 + TRIGGER.length + 'SHELL-PROMPT'.length, + transformed: false + }) + expect(barrier.getOwner()).toBe('shell') + }) + + it('queues later emissions during an episode and preserves order', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough(TRIGGER, 0)) + barrier.accept(passthrough('late-1', TRIGGER.length)) + barrier.accept(passthrough('late-2', TRIGGER.length + 6)) + expect(released).toHaveLength(1) + + resolveConfirm?.(true) + await vi.waitFor(() => expect(released).toHaveLength(4)) + expect(released.map((emission) => emission.data)).toEqual([ + TRIGGER, + POST_REPLAY_DEAD_TUI_RESET, + 'late-1', + 'late-2' + ]) + }) + + it('flushes unmodified with no injection when the proof is refuted', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough(`${TRIGGER}nested-shell`)) + resolveConfirm?.(false) + + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'nested-shell']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('bails out on timeout and ignores a late confirmation', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)), + maxPendingMs: 20 + }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + + resolveConfirm?.(true) + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(released).toHaveLength(2) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('bails out when the queue exceeds its byte budget', () => { + const { barrier, released } = createBarrier({ + confirm: () => new Promise(() => {}), + maxQueuedBytes: 8 + }) + + barrier.accept(passthrough(TRIGGER)) + barrier.accept(passthrough('0123456789')) + + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, '0123456789']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('does not inject when the session died while the proof settled', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + let alive = true + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)), + isAlive: () => alive + }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + alive = false + resolveConfirm?.(true) + + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('handles a second unclean episode queued behind the first', async () => { + const confirms: ((confirmed: boolean) => void)[] = [] + const { barrier, released, confirm } = createBarrier({ + confirm: () => new Promise((resolve) => void confirms.push(resolve)) + }) + + barrier.accept(passthrough(`${TRIGGER}first-prompt`, 0)) + const second = `\x1b[?1049hAGAIN\x1b]133;D;9\x07second-prompt` + barrier.accept(passthrough(second, TRIGGER.length + 'first-prompt'.length)) + + confirms[0]?.(true) + await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(2)) + confirms[1]?.(true) + + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + TRIGGER, + POST_REPLAY_DEAD_TUI_RESET, + 'first-prompt', + '\x1b[?1049hAGAIN\x1b]133;D;9\x07', + POST_REPLAY_DEAD_TUI_RESET, + 'second-prompt' + ]) + ) + expect(barrier.getOwner()).toBe('shell') + }) + + it('does not re-trigger on the next command-done after an injected recovery', async () => { + const { barrier, released, confirm } = createBarrier() + barrier.accept(passthrough(`${TRIGGER}prompt`)) + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + expect(confirm).toHaveBeenCalledTimes(1) + + // Why: the injected reset's ?1049l must ground the scanner's alt state; + // a bare D at the next plain prompt would otherwise loop the repair. + barrier.accept(passthrough('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07')) + + expect(confirm).toHaveBeenCalledTimes(1) + expect(released.at(-1)?.data).toBe('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07') + }) + + it('proves clean alternate-screen exits without pausing the stream', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released, confirm } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + const emission = passthrough('\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07PROMPT') + + barrier.accept(emission) + expect(released).toEqual([emission]) + expect(confirm).toHaveBeenCalledTimes(1) + expect(barrier.getOwner()).toBeUndefined() + + resolveConfirm?.(true) + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + }) + + it('rejects a clean-exit proof superseded by a later command start', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough('\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07')) + barrier.accept(passthrough('\x1b]133;C\x07LIVE-TUI')) + resolveConfirm?.(true) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(barrier.getOwner()).toBeUndefined() + }) + + it('revokes proven ownership when a later TUI arms modes', async () => { + const { barrier } = createBarrier() + barrier.accept(passthrough(TRIGGER)) + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + + barrier.accept(passthrough('\x1b[?1003hLIVE')) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('resolves idle() at each episode boundary', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + await expect(barrier.idle()).resolves.toBeUndefined() + + barrier.accept(passthrough(TRIGGER)) + let idled = false + const idle = barrier.idle().then(() => { + idled = true + }) + await Promise.resolve() + expect(idled).toBe(false) + + resolveConfirm?.(true) + await idle + }) + + it('skips the episode for a transformed emission it cannot split', () => { + const { barrier, released, confirm } = createBarrier() + const emission: PtyIngressEmission = { + data: `${TRIGGER}prompt`, + rawStartSeq: 0, + rawEndSeq: 4, + transformed: true + } + barrier.accept(emission) + + expect(released).toEqual([emission]) + expect(confirm).not.toHaveBeenCalled() + expect(barrier.getOwner()).toBeUndefined() + }) + + it('recognizes a trigger split across emissions and splits the completing one', async () => { + const { barrier, released } = createBarrier() + const head = '\x1b[?1049hTUI\x1b]133;D;1' + const tail = '37\x07PROMPT' + barrier.accept(passthrough(head, 0)) + barrier.accept(passthrough(tail, head.length)) + + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + head, + '37\x07', + POST_REPLAY_DEAD_TUI_RESET, + 'PROMPT' + ]) + ) + expect(released[1]).toMatchObject({ rawStartSeq: head.length, rawEndSeq: head.length + 3 }) + expect(released[3]).toMatchObject({ + rawStartSeq: head.length + 3, + rawEndSeq: head.length + tail.length + }) + }) + + it('survives a throwing downstream client without losing the queue or wedging attach', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const released: PtyIngressEmission[] = [] + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: () => new Promise((resolve) => void (resolveConfirm = resolve)), + release: (emission) => { + if (emission.data === 'poison') { + throw new Error('client transport died') + } + released.push(emission) + }, + isAlive: () => true + }) + + barrier.accept(passthrough(TRIGGER, 0)) + barrier.accept(passthrough('poison', TRIGGER.length)) + barrier.accept(passthrough('after-poison', TRIGGER.length + 6)) + const settled = barrier.awaitProofSettled() + resolveConfirm?.(true) + + await settled + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + TRIGGER, + POST_REPLAY_DEAD_TUI_RESET, + 'after-poison' + ]) + ) + await expect(barrier.idle()).resolves.toBeUndefined() + }) + + it('opens at most one episode per alternate-screen occupancy after a refuted proof', async () => { + const { barrier, released, confirm } = createBarrier({ confirm: async () => false }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(confirm).toHaveBeenCalledTimes(1) + + // Why: the refuted path never scans a reset, so alt stays active — later + // ordinary prompts must not each re-open a pause-and-inspect episode. + for (let index = 0; index < 5; index += 1) { + const prompt = passthrough(`\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07`) + barrier.accept(prompt) + expect(released.at(-1)).toBe(prompt) + } + expect(confirm).toHaveBeenCalledTimes(1) + + // A fresh alternate-screen entry re-arms recovery. + barrier.accept(passthrough(`\x1b]133;C\x07\x1b[?1049hAGAIN\x1b]133;D;9\x07`)) + expect(confirm).toHaveBeenCalledTimes(2) + }) + + it('bounds awaitProofSettled by its own deadline when a clean-exit proof hangs', async () => { + const { barrier } = createBarrier({ + confirm: () => new Promise(() => {}), + maxPendingMs: 20 + }) + + barrier.accept(passthrough('\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07')) + const start = Date.now() + await barrier.awaitProofSettled() + expect(Date.now() - start).toBeLessThan(500) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('still opens the episode when the trigger-carrying head release throws', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const released: PtyIngressEmission[] = [] + const confirm = vi.fn(() => new Promise((resolve) => void (resolveConfirm = resolve))) + let headThrown = false + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: confirm, + release: (emission) => { + if (!headThrown && emission.data === TRIGGER) { + headThrown = true + throw new Error('client transport died mid-broadcast') + } + released.push(emission) + }, + isAlive: () => true + }) + + barrier.accept(passthrough(`${TRIGGER}SHELL-PROMPT`)) + expect(confirm).toHaveBeenCalledTimes(1) + resolveConfirm?.(true) + + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + POST_REPLAY_DEAD_TUI_RESET, + 'SHELL-PROMPT' + ]) + ) + expect(barrier.getOwner()).toBe('shell') + }) + + it('flushes a pending episode synchronously for a teardown snapshot', () => { + const { barrier, released } = createBarrier({ confirm: () => new Promise(() => {}) }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER]) + + barrier.flushPending() + + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('drops queued output only on disposal', () => { + const { barrier, released } = createBarrier({ confirm: () => new Promise(() => {}) }) + barrier.accept(passthrough(`${TRIGGER}prompt`)) + barrier.dispose() + + expect(released.map((emission) => emission.data)).toEqual([TRIGGER]) + barrier.accept(passthrough('after-dispose')) + expect(released).toHaveLength(1) + }) +}) diff --git a/src/main/daemon/terminal-shell-recovery-barrier.ts b/src/main/daemon/terminal-shell-recovery-barrier.ts new file mode 100644 index 00000000000..7f4a94e9861 --- /dev/null +++ b/src/main/daemon/terminal-shell-recovery-barrier.ts @@ -0,0 +1,338 @@ +import { POST_REPLAY_DEAD_TUI_RESET } from '../../shared/terminal-mode-reset-profiles' +import { TerminalShellCleanExitConfirmation } from './terminal-shell-clean-exit-confirmation' +import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' +import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' +import type { TerminalOwner } from '../../shared/terminal-owner' + +// Why bounded: a pause only bridges one process proof (~100ms measured p95); a +// flood or hang means the trigger misfired, so bail out and flush unmodified. +const MAX_QUEUED_BYTES = 262_144 +const MAX_PENDING_MS = 750 + +export type TerminalShellRecoveryBarrierOptions = { + /** Fresh execution-host proof that the spawned shell owns the PTY foreground. */ + confirmShellForeground: () => Promise + /** Ordered downstream sink (emulator write + record + client broadcast). */ + release: (emission: PtyIngressEmission) => void + isAlive: () => boolean + maxQueuedBytes?: number + maxPendingMs?: number +} + +/** + * Ordered output barrier for dead-TUI mode recovery. Sits between startup + * ingress and the output plane. When a shell-integration command-done marker + * (OSC 133;D) arrives while the alternate screen is still active, the stream + * pauses at that exact byte boundary, the execution host proves the shell owns + * the PTY foreground, and on proof a mode reset is injected as in-stream output + * so every downstream consumer (host emulator, mirrors, attached renderers, + * history) converges — and the queued shell prompt then paints onto the normal + * buffer instead of the discarded alternate screen. Any failure (refuted proof, + * timeout, overflow, death, disposal) flushes the queue unmodified, preserving + * incumbent behavior. Clean alternate-screen exits prove ownership without + * pausing: the model needs no correction, only snapshot metadata. + */ +export class TerminalShellRecoveryBarrier { + private readonly scanner = new TerminalShellLifecycleScanner() + private readonly confirmShellForeground: () => Promise + private readonly releaseDownstream: (emission: PtyIngressEmission) => void + private readonly isAlive: () => boolean + private readonly maxQueuedBytes: number + private readonly maxPendingMs: number + + private queue: PtyIngressEmission[] = [] + private queuedBytes = 0 + private pending = false + private pendingGeneration = 0 + private pendingRawSeq = 0 + private pendingEpisode = 0 + private bailTimer: ReturnType | null = null + private idleWaiters: (() => void)[] = [] + private readonly cleanExit: TerminalShellCleanExitConfirmation + private disposed = false + + constructor(opts: TerminalShellRecoveryBarrierOptions) { + this.confirmShellForeground = opts.confirmShellForeground + this.releaseDownstream = opts.release + this.isAlive = opts.isAlive + this.maxQueuedBytes = opts.maxQueuedBytes ?? MAX_QUEUED_BYTES + this.maxPendingMs = opts.maxPendingMs ?? MAX_PENDING_MS + this.cleanExit = new TerminalShellCleanExitConfirmation({ + scanner: this.scanner, + confirmShellForeground: opts.confirmShellForeground, + deadlineMs: () => this.maxPendingMs, + isDisposed: () => this.disposed, + isAlive: opts.isAlive + }) + } + + accept(emission: PtyIngressEmission): void { + if (this.disposed) { + return + } + if (this.pending) { + this.enqueue(emission) + return + } + this.scanAndRelease(emission) + } + + getOwner(): TerminalOwner | undefined { + return this.scanner.owner + } + + /** Answers a paired runtime's ownership question from the barrier's settled + * state. The barrier scans bytes before any consumer receives them, so its + * verdict is never behind the caller's parse position — a fresh process + * inspection here would prove nothing the barrier didn't already. */ + async confirmOwnerSettled(): Promise { + if (this.scanner.owner === 'shell') { + return true + } + await this.awaitProofSettled() + return this.scanner.owner === 'shell' + } + + /** Synchronous bail for teardown: resolves the current episode as refuted so + * the queued bytes reach the emulator and records before a final snapshot. + * Queue replay can re-enter pending on a nested trigger, so drain to + * completion — bounded, because a pathological stream must not spin teardown. */ + flushPending(): void { + for (let guard = 0; this.pending && guard < 16; guard += 1) { + this.finishPending(this.pendingEpisode, false) + } + if (!this.pending) { + return + } + // Pathological episode storm: stop rescanning but never drop bytes. The + // scanner's model is stale from here on — a checkpoint after this may + // publish a stale owner, acceptable only past a >16-nested-episode storm. + const queued = this.queue + this.queue = [] + this.queuedBytes = 0 + this.pending = false + if (this.bailTimer) { + clearTimeout(this.bailTimer) + this.bailTimer = null + } + for (const emission of queued) { + try { + this.releaseDownstream(emission) + } catch { + // One throwing client must not cost the rest of the queue its delivery. + } + } + this.resolveIdleWaiters() + } + + /** Resolves when the current recovery episode (if any) has settled. Bounded + * by the bail timer; resolves at each episode boundary so a hostile stream + * of back-to-back episodes cannot starve an attach. */ + idle(): Promise { + if (!this.pending) { + return Promise.resolve() + } + return new Promise((resolve) => this.idleWaiters.push(resolve)) + } + + /** Like idle(), but also waits out an in-flight clean-exit proof so snapshot + * and RPC readers see a settled owner. A generation advance past the entry + * point ends the wait (that proof is stale), and the wait itself is bounded + * by the barrier's own budget — attach latency must never inherit a slow + * out-of-process inspection; an unsettled proof just reads as no owner. */ + async awaitProofSettled(): Promise { + const target = this.scanner.generation + let deadlineHit = false + const deadline = setTimeout(() => { + deadlineHit = true + this.resolveIdleWaiters() + this.cleanExit.drainWaiters() + }, this.maxPendingMs) + deadline.unref?.() + try { + while ( + (this.pending || this.cleanExit.inFlight) && + this.scanner.generation <= target && + !this.disposed && + !deadlineHit + ) { + await (this.pending ? this.idle() : this.cleanExit.waitSettled()) + } + } finally { + clearTimeout(deadline) + } + } + + dispose(): void { + this.disposed = true + if (this.bailTimer) { + clearTimeout(this.bailTimer) + this.bailTimer = null + } + // Callers flush first (exit, dispose, prepareForFinalSnapshot); anything + // still queued here has no live downstream left to receive it. + this.queue = [] + this.queuedBytes = 0 + this.pending = false + this.resolveIdleWaiters() + this.cleanExit.drainWaiters() + } + + private scanAndRelease(emission: PtyIngressEmission): void { + const events = this.scanner.scan(emission.data) + if (events.cleanExitCandidate) { + this.cleanExit.start(events.cleanExitCandidate.generation) + } + const end = events.uncleanDeathTriggerEnd + if (end === undefined) { + this.releaseDownstream(emission) + return + } + const splittable = + !emission.transformed && emission.rawEndSeq - emission.rawStartSeq === emission.data.length + if (!splittable) { + // Why skip the episode: the raw-seq boundary inside a transformed emission + // cannot be reconstructed, so release everything and keep the scanner + // honest about the remainder — incumbent behavior for this rare corner. + try { + this.releaseDownstream(emission) + } catch { + // Why swallowed: emit already wrote and recorded before a client threw; + // the scanner must still advance past the remainder below. + } + this.consumeForStateOnly(emission.data.slice(end)) + return + } + const splitSeq = emission.rawStartSeq + end + try { + this.releaseDownstream({ + data: emission.data.slice(0, end), + rawStartSeq: emission.rawStartSeq, + rawEndSeq: splitSeq, + transformed: false + }) + } catch { + // Why swallowed: the emulator and records already took the head inside + // emit before a client's broadcast threw; aborting here would cost the + // post-boundary prompt its entire recovery episode. + } + this.enterPending(splitSeq) + if (end < emission.data.length) { + this.enqueue({ + data: emission.data.slice(end), + rawStartSeq: splitSeq, + rawEndSeq: emission.rawEndSeq, + transformed: false + }) + } + } + + private consumeForStateOnly(data: string): void { + let rest = data + while (rest.length > 0) { + const events = this.scanner.scan(rest) + if (events.cleanExitCandidate) { + this.cleanExit.start(events.cleanExitCandidate.generation) + } + if (events.uncleanDeathTriggerEnd === undefined) { + return + } + rest = rest.slice(events.uncleanDeathTriggerEnd) + } + } + + private enterPending(rawSeq: number): void { + this.pending = true + this.pendingEpisode += 1 + this.pendingGeneration = this.scanner.generation + this.pendingRawSeq = rawSeq + const episode = this.pendingEpisode + this.bailTimer = setTimeout(() => this.finishPending(episode, false), this.maxPendingMs) + this.bailTimer.unref?.() + // Why the guard: the callback is injected; a synchronous throw must not + // escape after pending flipped true and strand the episode until the bail. + let proof: Promise + try { + proof = this.confirmShellForeground() + } catch { + proof = Promise.resolve(false) + } + // Why the two-step then: a throw escaping finishPending must not re-enter + // it through a catch arm as a phantom refutation. + void proof + .then( + (confirmed) => confirmed, + () => false + ) + .then((confirmed) => this.finishPending(episode, confirmed)) + } + + private finishPending(episode: number, confirmed: boolean): void { + if (!this.pending || this.disposed || episode !== this.pendingEpisode) { + return + } + this.pending = false + if (this.bailTimer) { + clearTimeout(this.bailTimer) + this.bailTimer = null + } + const queued = this.queue + this.queue = [] + this.queuedBytes = 0 + try { + if (confirmed && this.isAlive()) { + // Scanned before release so alt-state stays honest; the reset bytes are + // deliberately inert for ownership (no OSC 133, no TUI mode enables). + this.scanner.scan(POST_REPLAY_DEAD_TUI_RESET) + try { + this.releaseDownstream({ + data: POST_REPLAY_DEAD_TUI_RESET, + rawStartSeq: this.pendingRawSeq, + rawEndSeq: this.pendingRawSeq, + transformed: true + }) + } catch { + // Why swallowed: a throwing downstream client must not strand the + // queued prompt bytes below. + } + this.scanner.trySetOwner(this.pendingGeneration) + } + for (let index = 0; index < queued.length; index += 1) { + if (this.disposed) { + break + } + if (this.pending) { + // A nested trigger inside the queue re-entered pending; requeue the rest. + this.enqueue(queued[index]!) + continue + } + try { + this.scanAndRelease(queued[index]!) + } catch { + // Why swallowed: one throwing downstream client must not cost the + // rest of the queue its delivery. + } + } + } finally { + // Why finally: an escaping throw must never orphan an attach waiting in + // settleShellOwnershipConfirmation — that wedges createOrAttach forever. + this.resolveIdleWaiters() + } + } + + private enqueue(emission: PtyIngressEmission): void { + this.queue.push(emission) + this.queuedBytes += emission.data.length + if (this.queuedBytes > this.maxQueuedBytes) { + this.finishPending(this.pendingEpisode, false) + } + } + + private resolveIdleWaiters(): void { + const waiters = this.idleWaiters + this.idleWaiters = [] + for (const waiter of waiters) { + waiter() + } + } +} diff --git a/src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts b/src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts new file mode 100644 index 00000000000..76349e202fd --- /dev/null +++ b/src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' + +const CLEAN_EXIT = '\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07' + +function passthrough(data: string, rawStartSeq = 0): PtyIngressEmission { + return { data, rawStartSeq, rawEndSeq: rawStartSeq + data.length, transformed: false } +} + +function createBarrier(confirm: () => Promise, maxPendingMs?: number) { + const released: string[] = [] + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: confirm, + release: (emission) => released.push(emission.data), + isAlive: () => true, + ...(maxPendingMs !== undefined ? { maxPendingMs } : {}) + }) + return Object.assign(barrier, { released }) +} + +describe('clean-exit confirmation retirement', () => { + it('retires a hung clean-exit proof so a later candidate can still prove ownership', async () => { + let calls = 0 + const barrier = createBarrier(() => { + calls += 1 + return calls === 1 ? new Promise(() => {}) : Promise.resolve(true) + }, 20) + + barrier.accept(passthrough(CLEAN_EXIT)) + expect(calls).toBe(1) + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBeUndefined() + + barrier.accept(passthrough(CLEAN_EXIT, CLEAN_EXIT.length)) + await vi.waitFor(() => expect(calls).toBe(2)) + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBe('shell') + }) + + it('contains a synchronously throwing confirm without wedging later candidates', async () => { + let calls = 0 + const barrier = createBarrier(() => { + calls += 1 + if (calls === 1) { + throw new Error('sync boom') + } + return Promise.resolve(true) + }) + + expect(() => barrier.accept(passthrough(CLEAN_EXIT))).not.toThrow() + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBeUndefined() + + barrier.accept(passthrough(CLEAN_EXIT, CLEAN_EXIT.length)) + await vi.waitFor(() => expect(calls).toBe(2)) + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBe('shell') + }) + + it('flushPending releases every byte of an episode storm past the rescan bound', () => { + const barrier = createBarrier(() => new Promise(() => {})) + const episode = '\x1b[?1049hT\x1b]133;D;1\x07x' + const storm = episode.repeat(40) + barrier.accept(passthrough(storm)) + barrier.flushPending() + + // Past the 16-pass rescan bound the remainder is released verbatim; the + // bound trades scanner-model fidelity at teardown, never bytes. + expect(barrier.released.join('')).toBe(storm) + }) +}) diff --git a/src/main/daemon/terminal-snapshot.ts b/src/main/daemon/terminal-snapshot.ts index 6d378a8d107..559d7f2ed08 100644 --- a/src/main/daemon/terminal-snapshot.ts +++ b/src/main/daemon/terminal-snapshot.ts @@ -1,5 +1,6 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalModes } from './terminal-modes' +import type { TerminalOwner } from '../../shared/terminal-owner' export type TerminalSnapshot = { snapshotAnsi: string @@ -19,4 +20,6 @@ export type TerminalSnapshot = { lastTitle?: string /** Optional because persisted snapshots and older v19 daemons lack it. */ outputSequence?: number + /** Ordered shell lifecycle evidence captured at this snapshot boundary. */ + terminalOwner?: TerminalOwner } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 2f1817d14c7..a90e9f5eee5 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,11 +1,13 @@ import type { ConfirmForegroundProcessRequest, + ConfirmShellForegroundRequest, GetForegroundProcessRequest, InspectProcessRequest } from './daemon-foreground-process-protocol' export type { ConfirmForegroundProcessRequest, + ConfirmShellForegroundRequest, GetForegroundProcessRequest, InspectProcessRequest } from './daemon-foreground-process-protocol' @@ -317,6 +319,7 @@ export type DaemonRequest = | GetForegroundProcessRequest | InspectProcessRequest | ConfirmForegroundProcessRequest + | ConfirmShellForegroundRequest | ClearScrollbackRequest | ShutdownRequest | PingRequest diff --git a/src/main/ipc/pty/ipc/snapshot.ts b/src/main/ipc/pty/ipc/snapshot.ts index 607b3bfe781..3724cf7ccfe 100644 --- a/src/main/ipc/pty/ipc/snapshot.ts +++ b/src/main/ipc/pty/ipc/snapshot.ts @@ -43,6 +43,7 @@ export function installPtySnapshotIpcHandlers(deps: { scrollbackAnsi?: string pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { return null diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 7e277c7c3f5..09e6fa8b8c8 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -35,14 +35,14 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise 0) { // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). - ctx.deps.runtime.seedHeadlessTerminal( - ctx.result.id, - ctx.result.snapshot, - snapshotSeedSize, - typeof ctx.result.snapshotKittyKeyboardFlags === 'number' + ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.snapshot, snapshotSeedSize, { + ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' ? { kittyKeyboardFlags: ctx.result.snapshotKittyKeyboardFlags } - : {} - ) + : {}), + ...(ctx.result.snapshotTerminalOwner + ? { terminalOwner: ctx.result.snapshotTerminalOwner } + : {}) + }) } else if ( ctx.result.coldRestore && typeof ctx.result.coldRestore.scrollback === 'string' && diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 4fce4853bc4..ff5a0e5ccd0 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -12,6 +12,7 @@ import { attachPtyFromRuntimeController, clearBufferFromRuntimeController, confirmForegroundProcessFromRuntimeController, + confirmShellForegroundFromRuntimeController, getCwdFromRuntimeController, getForegroundProcessFromRuntimeController, getRendererSerializerGenerationFromRuntimeController, @@ -55,6 +56,7 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), inspectProcess: (ptyId) => inspectProcessFromRuntimeController(ptyId), confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId), + confirmShellForeground: (ptyId) => confirmShellForegroundFromRuntimeController(ptyId), getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 745f225312a..bf3ca74af25 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -114,6 +114,14 @@ export async function confirmForegroundProcessFromRuntimeController(ptyId: strin } } +export async function confirmShellForegroundFromRuntimeController(ptyId: string) { + try { + return (await getProviderForPty(ptyId).confirmShellForeground?.(ptyId)) ?? false + } catch { + return false + } +} + export async function getCwdFromRuntimeController(ptyId: string) { try { const cwd = await getProviderForPty(ptyId).getCwd(ptyId) diff --git a/src/main/providers/agent-foreground-process-windows-job-rejection.test.ts b/src/main/providers/agent-foreground-process-windows-job-rejection.test.ts new file mode 100644 index 00000000000..43fe6f2de53 --- /dev/null +++ b/src/main/providers/agent-foreground-process-windows-job-rejection.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { confirmShellForegroundProcess } from './agent-foreground-process' + +const realPlatform = process.platform +afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform }) +}) + +describe('Windows shell confirmation with a rejecting job reader', () => { + it('fails closed instead of rejecting the confirmation', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + // A rejecting reader is missing proof, never a thrown confirmation. + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => { + throw new Error('job handle gone') + } + }) + ).resolves.toBe(false) + }) +}) diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index a1e02d02fc5..76de68fa0e8 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -13,6 +13,7 @@ const getAllProcessesMock = vi.fn() import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot' import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { + confirmShellForegroundProcess, resolveAgentForegroundProcess, resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' @@ -219,6 +220,106 @@ describe('resolveAgentForegroundProcess', () => { await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') }) + it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => { + mockPs(['100 99 Ss+ "/bin/zsh" -l', '101 100 S+ /bin/bash'].join('\n')) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true) + }) + + it('uses spawned-shell identity instead of a lagging foreground child label', async () => { + mockPs(['100 99 Ss+ /bin/zsh -l'].join('\n')) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) + }) + + it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => { + mockPs( + [ + '100 99 Ss /usr/bin/login -pfl developer /bin/zsh', + '101 100 S+ -zsh', + '102 101 S+ (zsh)', + '103 102 S+ (sed)', + '104 102 R+ (git)' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) + }) + + it('rejects a foreground nested shell while the spawned shell remains suspended', async () => { + mockPs( + [ + '100 99 Ss /usr/bin/login -pfl developer /bin/zsh', + '101 100 S -zsh', + '102 101 S+ agent-tui', + '103 102 S+ /bin/zsh -i' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(false) + }) + + it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => { + mockPs( + [ + '100 99 Ss /bin/zsh -l', + '101 100 S+ /usr/local/bin/agent-tui', + '102 101 S+ /bin/bash -i' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('rejects shell ownership while a stopped TUI remains resumable', async () => { + mockPs(['100 99 Ss+ /bin/zsh -l', '101 100 T /usr/local/bin/agent-tui'].join('\n')) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('refuses WSL shells: wsl.exe is not a provable shell identity', async () => { + // Why pinned: the WSL job object holds only wsl.exe, so a looser + // isShellProcess would confirm ownership regardless of distro-side state. + await expect(confirmShellForegroundProcess(100, 'wsl.exe')).resolves.toBe(false) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('fails open when fresh shell ownership inspection is unavailable', async () => { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) + } + ) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('confirms a Windows shell from fresh root-only ConPTY membership', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const readWindowsPtyJobProcessIds = vi.fn(async () => new Set([100])) + + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { readWindowsPtyJobProcessIds }) + ).resolves.toBe(true) + expect(getAllProcessesMock).not.toHaveBeenCalled() + }) + + it('rejects Windows shell ownership with a child or unavailable membership', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => new Set([100, 101]) + }) + ).resolves.toBe(false) + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => null + }) + ).resolves.toBe(false) + }) + it('does not report Claude print-mode hook descendants as foreground agents', async () => { mockPs( [ diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 5242bb2b911..ef5d6cb58f8 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -10,6 +10,7 @@ import { shouldInspectWindowsAgentForeground, type AgentForegroundResolutionOptions } from './windows-agent-foreground-process' +import { isShellProcess } from '../../shared/shell-process-detection' export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process' @@ -26,6 +27,13 @@ export type AgentForegroundProcessResolution = { anchorPidForeign?: boolean } +type ShellForegroundConfirmationOptions = { + readWindowsPtyJobProcessIds?: () => + | ReadonlySet + | null + | Promise | null> +} + function collectDescendants( rows: Row[], rootPid: number @@ -49,6 +57,61 @@ function collectDescendants( return descendants } +function commandExecutable(command: string): string { + const trimmed = command.trim().replace(/^[-]/, '') + if (trimmed.startsWith('"') || trimmed.startsWith("'")) { + const closingQuote = trimmed.indexOf(trimmed[0], 1) + return closingQuote === -1 ? trimmed.slice(1) : trimmed.slice(1, closingQuote) + } + return trimmed.split(/\s+/, 1)[0] ?? '' +} + +function executableBasename(command: string): string { + return commandExecutable(command).split(/[\\/]/).pop()?.toLowerCase() ?? '' +} + +export async function confirmShellForegroundProcess( + shellPid: number | null | undefined, + spawnedShellProcess: string | null | undefined, + options: ShellForegroundConfirmationOptions = {} +): Promise { + if (!shellPid || !spawnedShellProcess || !isShellProcess(spawnedShellProcess)) { + return false + } + if (process.platform === 'win32') { + try { + const processIds = await options.readWindowsPtyJobProcessIds?.() + return processIds?.size === 1 && processIds.has(shellPid) + } catch { + // Unavailable job inspection is missing proof, never a thrown confirmation. + return false + } + } + try { + const rows = await getFreshProcessTableSnapshot() + if (!rows.some((row) => row.pid === shellPid)) { + return false + } + const root = rows.find((row) => row.pid === shellPid)! + const tree = [{ ...root, depth: 0 }, ...collectDescendants(rows, shellPid)] + const spawnedShellBasename = executableBasename(spawnedShellProcess) + const foregroundShell = tree + .filter( + (row) => + executableBasename(row.command) === spawnedShellBasename && + isShellProcess(commandExecutable(row.command)) + ) + .sort((left, right) => left.depth - right.depth)[0] + if (tree.some((row) => row.depth > 0 && row.stat.includes('T'))) { + return false + } + const confirmed = foregroundShell?.stat.includes('+') === true + return confirmed + } catch { + return false + } +} + function candidateScore(row: ProcessTableRow & { depth: number }): number { // Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer // them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index d8ddc7cd902..223168b65c0 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -59,7 +59,10 @@ import { resolveWindowsGitBashShellPath } from '../git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' +import { + confirmShellForegroundProcess, + resolveAgentForegroundProcessWithAvailability +} from './agent-foreground-process' import { resolveStableForegroundProcess } from './stable-foreground-process' import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' @@ -1563,6 +1566,21 @@ export class LocalPtyProvider implements IPtyProvider { } } + async confirmShellForeground(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return false + } + const confirmed = await confirmShellForegroundProcess( + proc.pid, + ptyShellName.get(id), + process.platform === 'win32' + ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(proc) } + : {} + ) + return ptyProcesses.get(id) === proc && confirmed + } + async serialize(_ids: string[]): Promise { return '{}' } diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 333d6fcc62c..d77d6aec1a5 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -11,6 +11,7 @@ import type { } from '../../shared/agent-session-host-authority' import type { PtyProcessInfo } from './pty-process-info' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { TerminalOwner } from '../../shared/terminal-owner' export type { PtyBackgroundStreamEvent, @@ -37,6 +38,8 @@ export type PtyProviderBufferSnapshot = { * boundary. Absent means the source could not prove them; readers must not * rewrite that silence into a known `0`. */ kittyKeyboardFlags?: number + /** Ordered ownership evidence proven at this snapshot's `seq`. */ + terminalOwner?: TerminalOwner } export type PtySpawnOptions = { @@ -209,6 +212,8 @@ export type IPtyProvider = { getForegroundProcess(id: string): Promise /** Strong process evidence captured after the caller's command boundary. */ confirmForegroundProcess?: (id: string) => Promise + /** Fresh execution-host proof that the spawned shell owns the PTY foreground. */ + confirmShellForeground?: (id: string) => Promise serialize(ids: string[]): Promise revive(state: string): Promise // Why: deadlineMs bounds the underlying RPC exactly like shutdown's deadlineMs. diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 76bb4179069..01cf9193f7e 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -3,6 +3,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' +import type { TerminalOwner } from '../../shared/terminal-owner' export type PtySpawnResult = { agentSessionEnsure?: AgentSessionClaimedSpawnResult @@ -52,6 +53,8 @@ export type PtySpawnResult = { /** Renderer-domain sequence main reconciled for the attach boundary those * flags describe. Set by main, not the provider. */ snapshotSeq?: number + /** Ordered ownership evidence proven by the provider snapshot. */ + snapshotTerminalOwner?: TerminalOwner /** True when the spawn reattached to an existing daemon session. */ isReattach?: boolean /** Last OSC title tracked by the daemon session the snapshot came from. diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 0fc8702750d..08a73109e5c 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -25,6 +25,13 @@ describe('SshPtyProvider', () => { expect(provider.canProvideAuthoritativeBufferSnapshot(scopedPty1)).toBe(false) }) + it('fails closed without foreground-shell proof on direct SSH', () => { + expect( + (provider as { confirmShellForeground?: unknown }).confirmShellForeground + ).toBeUndefined() + expect(mux.request).not.toHaveBeenCalled() + }) + it('keeps a shared claim probe alive when one waiter disconnects', async () => { let finishProbe!: (result: { agentSessionClaimVersion: number }) => void mux.request.mockReturnValueOnce( diff --git a/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts b/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts new file mode 100644 index 00000000000..7fe33b66ee1 --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type * as GitUsernameModule from '../git/git-username' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../hooks', () => ({ + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) +vi.mock('../worktree-runner-script', () => ({ createSetupRunnerScript: vi.fn() })) + +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) + +vi.mock('../ipc/registered-worktree-roots-cache', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) + +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null) + } +}) + +vi.mock('../git/git-username', async () => { + const actual = await vi.importActual('../git/git-username') + return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), + addRetiredWorktreeName: () => {}, + mergeRetiredWorktreeNames: () => false, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + mobileAutoRestoreFitMs: null + }), + updateSettings: () => {} +} + +type SessionTabsPrivate = { + mobileSessionTabsByWorktree: Map + touchMobileSessionSnapshotsForPty: (ptyId: string) => void + notifyMobileSessionTabsChanged: (worktreeId?: string) => void +} + +function seedPtyBackedSnapshot(runtime: OrcaRuntimeService, ptyId: string): void { + const priv = runtime as unknown as SessionTabsPrivate + priv.mobileSessionTabsByWorktree.set('worktree-a', { + worktree: 'worktree-a', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1', + title: 'agent', + parentTabId: 'group-1', + leafId: 'leaf-1', + ptyId, + isActive: true + } + ] + }) +} + +describe('session.tabs title/status churn coalescing', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('collapses a rapid title-flip burst into a single emit (repro)', () => { + const runtime = new OrcaRuntimeService(store) + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // A spinner-in-title agent flips the title ~20 times within a second. + const FLIPS = 20 + for (let i = 0; i < FLIPS; i++) { + priv.touchMobileSessionSnapshotsForPty('pty-1') + vi.advanceTimersByTime(10) + } + // Nothing has fired yet — all flips landed inside the trailing window. + expect(emits).toHaveLength(0) + + vi.advanceTimersByTime(50) + + // Pre-fix this fanned out one emit per flip; now it is a single emit. + expect(emits).toHaveLength(1) + // The emitted version is the freshest (monotonic, never a stale one). + const stored = priv.mobileSessionTabsByWorktree.get('worktree-a') + expect(emits[0]).toBe(stored?.snapshotVersion) + expect(emits[0]).toBe(1 + FLIPS) + + unsubscribe() + }) + + it('lets a structural change emit immediately and supersede the coalesced notify', () => { + const runtime = new OrcaRuntimeService(store) + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // Title churn schedules a coalesced notify... + priv.touchMobileSessionSnapshotsForPty('pty-1') + // ...then a structural change (e.g. tab activated) demands a prompt emit. + priv.notifyMobileSessionTabsChanged('worktree-a') + expect(emits).toHaveLength(1) + + // The immediate emit cancelled the pending coalesced notify — no duplicate trailing emit. + vi.advanceTimersByTime(50) + expect(emits).toHaveLength(1) + + unsubscribe() + }) + + it('flushes a pending notify when the last subscriber closes', () => { + const runtime = new OrcaRuntimeService(store) + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + priv.touchMobileSessionSnapshotsForPty('pty-1') + expect(emits).toHaveLength(0) + + // Closing the subscription flushes the pending window so the final state reaches the listener. + unsubscribe() + expect(emits).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index 292cc13a816..4a7195e3963 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -3,7 +3,6 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type * as GitUsernameModule from '../git/git-username' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { OrcaRuntimeService } from './orca-runtime' vi.mock('../git/worktree', () => ({ @@ -211,6 +210,7 @@ describe('mobile subscribe integration', () => { } outputSequence: number writeChain: Promise + ownership: { settle: () => Promise; owner: undefined } } const runtimePrivate = runtime as unknown as { headlessTerminals: Map @@ -221,7 +221,8 @@ describe('mobile subscribe integration', () => { getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 }) }, outputSequence: 17, - writeChain: Promise.resolve() + writeChain: Promise.resolve(), + ownership: { settle: async () => {}, owner: undefined } }) await expect(runtime.serializeMainTerminalBuffer('pty-empty')).resolves.toEqual({ @@ -979,107 +980,4 @@ describe('mobile subscribe integration', () => { expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) }) }) - - describe('session.tabs title/status churn coalescing', () => { - type SessionTabsPrivate = { - mobileSessionTabsByWorktree: Map - touchMobileSessionSnapshotsForPty: (ptyId: string) => void - notifyMobileSessionTabsChanged: (worktreeId?: string) => void - } - - function seedPtyBackedSnapshot(runtime: OrcaRuntimeService, ptyId: string): void { - const priv = runtime as unknown as SessionTabsPrivate - priv.mobileSessionTabsByWorktree.set('worktree-a', { - worktree: 'worktree-a', - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: 'group-1', - activeTabId: 'tab-1', - activeTabType: 'terminal', - tabs: [ - { - type: 'terminal', - id: 'tab-1', - title: 'agent', - parentTabId: 'group-1', - leafId: 'leaf-1', - ptyId, - isActive: true - } - ] - }) - } - - it('collapses a rapid title-flip burst into a single emit (repro)', () => { - const { runtime } = createRuntime() - seedPtyBackedSnapshot(runtime, 'pty-1') - const priv = runtime as unknown as SessionTabsPrivate - - const emits: number[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emits.push(snapshot.snapshotVersion) - }) - - // A spinner-in-title agent flips the title ~20 times within a second. - const FLIPS = 20 - for (let i = 0; i < FLIPS; i++) { - priv.touchMobileSessionSnapshotsForPty('pty-1') - vi.advanceTimersByTime(10) - } - // Nothing has fired yet — all flips landed inside the trailing window. - expect(emits).toHaveLength(0) - - vi.advanceTimersByTime(50) - - // Pre-fix this fanned out one emit per flip; now it is a single emit. - expect(emits).toHaveLength(1) - // The emitted version is the freshest (monotonic, never a stale one). - const stored = priv.mobileSessionTabsByWorktree.get('worktree-a') - expect(emits[0]).toBe(stored?.snapshotVersion) - expect(emits[0]).toBe(1 + FLIPS) - - unsubscribe() - }) - - it('lets a structural change emit immediately and supersede the coalesced notify', () => { - const { runtime } = createRuntime() - seedPtyBackedSnapshot(runtime, 'pty-1') - const priv = runtime as unknown as SessionTabsPrivate - - const emits: number[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emits.push(snapshot.snapshotVersion) - }) - - // Title churn schedules a coalesced notify... - priv.touchMobileSessionSnapshotsForPty('pty-1') - // ...then a structural change (e.g. tab activated) demands a prompt emit. - priv.notifyMobileSessionTabsChanged('worktree-a') - expect(emits).toHaveLength(1) - - // The immediate emit cancelled the pending coalesced notify — no duplicate trailing emit. - vi.advanceTimersByTime(50) - expect(emits).toHaveLength(1) - - unsubscribe() - }) - - it('flushes a pending notify when the last subscriber closes', () => { - const { runtime } = createRuntime() - seedPtyBackedSnapshot(runtime, 'pty-1') - const priv = runtime as unknown as SessionTabsPrivate - - const emits: number[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emits.push(snapshot.snapshotVersion) - }) - - priv.touchMobileSessionSnapshotsForPty('pty-1') - expect(emits).toHaveLength(0) - - // Closing the subscription flushes the pending window so the final state reaches the listener. - unsubscribe() - expect(emits).toHaveLength(1) - }) - }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ea21fda6fff..60926fc873c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11813,6 +11813,50 @@ describe('OrcaRuntimeService', () => { }) }) + it('binds shell ownership evidence to the headless snapshot sequence', async () => { + const runtime = createRuntime() + let resolveConfirmation: ((confirmed: boolean) => void) | undefined + const confirmShellForeground = vi.fn( + () => new Promise((resolve) => void (resolveConfirmation = resolve)) + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + confirmShellForeground + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', '\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker', 100) + const shellSnapshotPromise = runtime.serializeHiddenOutputRecoveryBuffer('pty-1') + await vi.waitFor(() => expect(confirmShellForeground).toHaveBeenCalledTimes(1)) + let snapshotSettled = false + void shellSnapshotPromise.then(() => { + snapshotSettled = true + }) + await Promise.resolve() + expect(snapshotSettled).toBe(false) + + resolveConfirmation?.(true) + const shellSnapshot = await shellSnapshotPromise + // Why alternateScreen stays true here: the mirror never rewrites its own + // model — without a daemon barrier injecting the reset in-stream (direct + // provider path), the snapshot publishes the poisoned mode alongside the + // proof and the renderer's dead-TUI branch grounds the pane. + expect(shellSnapshot).toMatchObject({ + alternateScreen: true, + terminalOwner: 'shell', + seq: '\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker'.length + }) + expect(confirmShellForeground).toHaveBeenCalledTimes(1) + + runtime.onPtyData('pty-1', '\x1b]133;C\x07\x1b[?1049hLIVE-TUI', 101) + const liveSnapshot = await runtime.serializeHiddenOutputRecoveryBuffer('pty-1') + + expect(liveSnapshot?.alternateScreen).toBe(true) + expect(liveSnapshot?.terminalOwner).toBeUndefined() + }) + it('keeps an empty headless snapshot authoritative for hidden-output recovery', async () => { const serializeBuffer = vi.fn().mockResolvedValue({ data: 'stale renderer content\r\n', @@ -11839,6 +11883,7 @@ describe('OrcaRuntimeService', () => { } outputSequence: number writeChain: Promise + ownership: { settle: () => Promise; owner: undefined } } const runtimePrivate = runtime as unknown as { headlessTerminals: Map @@ -11849,7 +11894,8 @@ describe('OrcaRuntimeService', () => { getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 }) }, outputSequence: 17, - writeChain: Promise.resolve() + writeChain: Promise.resolve(), + ownership: { settle: async () => {}, owner: undefined } }) await expect(runtime.serializeHiddenOutputRecoveryBuffer('pty-empty')).resolves.toEqual({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 593a5f1db41..b8196482870 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1199,6 +1199,7 @@ import { } from '../ipc/watcher-removal-drain' import { withWorktreeSpan } from '../observability/instrumentation' import { HeadlessEmulator } from '../daemon/headless-emulator' +import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' import { isNativeWindowsConptyPty, registerConptyDa1OverrideInstaller, @@ -1863,6 +1864,7 @@ type RuntimeHeadlessTerminal = { // the seq actually painted into this emulator, not the latest PTY seq. outputSequence: number writeChain: Promise + ownership: PtyShellOwnershipMirror } export type RuntimePtyDataAdmission = Readonly<{ @@ -1916,6 +1918,7 @@ type RuntimeTerminalBufferSnapshot = { /** Effective kitty flags proven at this snapshot's own `seq`. Absent means * the winning source could not prove them. */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } type HeadlessSeedMetadata = { @@ -1927,6 +1930,7 @@ type HeadlessSeedMetadata = { * emulator so hidden `CSI ? u` answers the real flags instead of ?0u * (terminal-query-authority.md §kitty). */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } type RuntimePtyController = { @@ -2022,6 +2026,7 @@ type RuntimePtyController = { ptyId: string ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> confirmForegroundProcess?(ptyId: string): Promise + confirmShellForeground?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean @@ -13150,6 +13155,7 @@ export class OrcaRuntimeService { oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean scrollbackAnsi?: string + terminalOwner?: 'shell' } | null> { return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) } @@ -13170,6 +13176,7 @@ export class OrcaRuntimeService { alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string + terminalOwner?: 'shell' } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, @@ -13270,6 +13277,12 @@ export class OrcaRuntimeService { if (metadata.oscLinks !== undefined) { state.emulator.setRestoredOscLinks(metadata.oscLinks) } + // Why derived from the emulator: the seed bytes bypass ownership.scan, + // so the scanner must inherit the restored alternate-screen state or a + // pane seeded mid-TUI never arms its recovery trigger. + state.ownership.seedOwner(metadata.terminalOwner, { + alternateScreen: state.emulator.isAlternateScreen + }) this.providerSnapshotPreferredPtys.delete(ptyId) }) .catch(() => { @@ -13377,6 +13390,9 @@ export class OrcaRuntimeService { // (they feed main's tracker only), so its serializer lastTitle can be // stale here. Prefer main's tracked title; the renderer's is only the // seed when main has observed none (fresh relaunch, cold tracker). + state.ownership.seedOwner(undefined, { + alternateScreen: state.emulator.isAlternateScreen + }) const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle if (seedTitle) { state.emulator.setLastTitle(seedTitle) @@ -13452,6 +13468,9 @@ export class OrcaRuntimeService { const completion = state.writeChain.then(async () => { // Why: the ingestion-time ownership decision is closed over this // chain link; async scheduling cannot retroactively change it. + // Why inside the chain: the ownership mirror must observe live bytes in + // the same total order as seeds (seedOwner also runs on this chain). + state.ownership.scan(data) await state.emulator.write(data, { forwardQueryReplies }) state.outputSequence = outputSequence }) @@ -13511,7 +13530,28 @@ export class OrcaRuntimeService { if (viewAttributes) { emulator.applyPushedViewAttributes(viewAttributes) } - state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } + const constructed: RuntimeHeadlessTerminal = { + emulator, + outputSequence: 0, + writeChain: Promise.resolve(), + ownership: new PtyShellOwnershipMirror(async () => { + const controller = this.ptyController + const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId) + if ( + !controller?.confirmShellForeground || + this.headlessTerminals.get(ptyId) !== constructed + ) { + return false + } + const confirmed = await controller.confirmShellForeground(ptyId) + return ( + confirmed && + this.headlessTerminals.get(ptyId) === constructed && + this.getPtyLifecycleGeneration(ptyId) === lifecycleGeneration + ) + }) + } + state = constructed return state } @@ -13563,6 +13603,9 @@ export class OrcaRuntimeService { if (snapshot.oscLinks !== undefined) { state.emulator.setRestoredOscLinks(snapshot.oscLinks) } + state.ownership.seedOwner(snapshot.terminalOwner, { + alternateScreen: state.emulator.isAlternateScreen + }) state.outputSequence = snapshot.seq }) .catch(() => { @@ -13621,6 +13664,7 @@ export class OrcaRuntimeService { alternateScreen?: boolean pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> { if (this.providerSnapshotPreferredPtys.has(ptyId)) { // Why: pre-attach stream bytes only form a suffix of restored state. A @@ -14101,6 +14145,7 @@ export class OrcaRuntimeService { alternateScreen?: boolean scrollbackAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' // Why: dangling mid-escape tail the restorer must write LAST, after any // reset, so the next live chunk completes it instead of rendering it // literally (Bug E / #7329). @@ -14111,11 +14156,12 @@ export class OrcaRuntimeService { return null } await state.writeChain + await state.ownership.settle() // Why: normal history is separated from an active alternate frame, so the // caller's scrollback policy can be honored without painting it into alt. - const isAlternateScreen = state.emulator.isAlternateScreen const scrollbackRows = opts.scrollbackRows ?? 0 const snapshot = state.emulator.getSnapshot({ scrollbackRows }) + const terminalOwner = state.ownership.owner const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi return data.length > 0 || opts.includeEmpty === true ? this.preferTrackedLastTitle(ptyId, { @@ -14138,10 +14184,11 @@ export class OrcaRuntimeService { ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), + ...(terminalOwner ? { terminalOwner } : {}), // Why: lets the renderer skip the destructive scrollback clear when // restoring an alt-screen snapshot — clearing wipes xterm's own // history that the TUI relies on for scroll-up after a tab return. - alternateScreen: isAlternateScreen, + alternateScreen: snapshot.modes?.alternateScreen ?? state.emulator.isAlternateScreen, // Why NOT folded into data: the renderer writes its post-replay // reset after data, and any ESC after a dangling partial aborts it. // The restorer writes this last (Bug E fix). @@ -14161,6 +14208,7 @@ export class OrcaRuntimeService { // sever the reply sink now so they cannot write to a respawned PTY that // reused this id (belt to the sink's state-identity check). state.emulator.disableQueryReplyForwarding() + state.ownership.dispose() state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) } diff --git a/src/main/runtime/pty-shell-ownership-mirror.test.ts b/src/main/runtime/pty-shell-ownership-mirror.test.ts new file mode 100644 index 00000000000..20468a194cd --- /dev/null +++ b/src/main/runtime/pty-shell-ownership-mirror.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' + +describe('PtyShellOwnershipMirror', () => { + it('arms the recovery trigger from a seeded alternate-screen state', async () => { + const confirm = vi.fn(async () => true) + const mirror = new PtyShellOwnershipMirror(confirm) + + // Restored bytes bypass scan(); the seed must carry the alt state or a + // pane reattached mid-TUI never proves ownership after the TUI dies. + mirror.seedOwner(undefined, { alternateScreen: true }) + mirror.scan('\x1b]133;D;137\x07') + + await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)) + await mirror.settle() + expect(mirror.owner).toBe('shell') + }) + + it('does not arm from a seed on the normal buffer', async () => { + const confirm = vi.fn(async () => true) + const mirror = new PtyShellOwnershipMirror(confirm) + + mirror.seedOwner(undefined, { alternateScreen: false }) + mirror.scan('\x1b]133;D;0\x07') + + expect(confirm).not.toHaveBeenCalled() + expect(mirror.owner).toBeUndefined() + }) + + it('revokes a seeded owner on later TUI bytes', () => { + const mirror = new PtyShellOwnershipMirror(async () => true) + mirror.seedOwner('shell', { alternateScreen: false }) + expect(mirror.owner).toBe('shell') + + mirror.scan('\x1b[?1003h') + expect(mirror.owner).toBeUndefined() + }) + + it('bounds settle() when a proof hangs', async () => { + const mirror = new PtyShellOwnershipMirror(() => new Promise(() => {})) + mirror.seedOwner(undefined, { alternateScreen: true }) + mirror.scan('\x1b]133;D;137\x07') + + const start = Date.now() + await mirror.settle() + expect(Date.now() - start).toBeLessThan(2000) + expect(mirror.owner).toBeUndefined() + }) + + it('retires a hung confirmation so a later candidate can still prove ownership', async () => { + let calls = 0 + const mirror = new PtyShellOwnershipMirror(() => { + calls += 1 + return calls === 1 ? new Promise(() => {}) : Promise.resolve(true) + }) + mirror.seedOwner(undefined, { alternateScreen: true }) + mirror.scan('\x1b]133;D;137\x07') + // The settle deadline outlives the attempt deadline, so this waits out the + // retirement of the hung attempt. + await mirror.settle() + expect(mirror.owner).toBeUndefined() + + mirror.scan('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(calls).toBe(2)) + await mirror.settle() + expect(mirror.owner).toBe('shell') + }) + + it('contains a synchronously throwing confirm callback', async () => { + let calls = 0 + const mirror = new PtyShellOwnershipMirror(() => { + calls += 1 + if (calls === 1) { + throw new Error('sync boom') + } + return Promise.resolve(true) + }) + mirror.seedOwner(undefined, { alternateScreen: true }) + expect(() => mirror.scan('\x1b]133;D;137\x07')).not.toThrow() + await mirror.settle() + expect(mirror.owner).toBeUndefined() + + mirror.scan('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(calls).toBe(2)) + await mirror.settle() + expect(mirror.owner).toBe('shell') + }) +}) diff --git a/src/main/runtime/pty-shell-ownership-mirror.ts b/src/main/runtime/pty-shell-ownership-mirror.ts new file mode 100644 index 00000000000..5c54465be75 --- /dev/null +++ b/src/main/runtime/pty-shell-ownership-mirror.ts @@ -0,0 +1,149 @@ +import { TerminalShellLifecycleScanner } from '../daemon/terminal-shell-lifecycle-scanner' +import type { TerminalOwner } from '../../shared/terminal-owner' + +// Why bounded: a serialize awaiting settle() must not inherit a slow daemon RPC +// or ps fork; a proof that lands later is still generation-guarded. +const SETTLE_DEADLINE_MS = 750 + +/** + * Mirror-side shell ownership for the runtime's per-PTY headless emulator. + * Never pauses or injects: for daemon-backed PTYs the host barrier already + * ordered the stream (any recovery reset arrives as ordinary bytes), so a + * trigger here is only a proof candidate answered by the host's settled + * verdict; for direct local PTYs the answer is a fresh process inspection. + * Fed the same bytes the emulator parses, in the same order. + */ +export class PtyShellOwnershipMirror { + private readonly scanner = new TerminalShellLifecycleScanner() + private confirmInFlight = false + private confirmAttempt = 0 + private latestCandidateGeneration: number | undefined + private settleWaiters: (() => void)[] = [] + private disposed = false + + constructor(private readonly confirm: () => Promise) {} + + scan(data: string): void { + if (this.disposed) { + return + } + let rest = data + while (rest.length > 0) { + const events = this.scanner.scan(rest) + if (events.cleanExitCandidate) { + this.startConfirmation(events.cleanExitCandidate.generation) + } + if (events.uncleanDeathTriggerEnd === undefined) { + return + } + // The stream owner already resolved this boundary; ask it, keep scanning. + this.startConfirmation(this.scanner.generation) + rest = rest.slice(events.uncleanDeathTriggerEnd) + } + } + + get owner(): TerminalOwner | undefined { + return this.scanner.owner + } + + seedOwner(owner: TerminalOwner | undefined, opts: { alternateScreen?: boolean } = {}): void { + this.scanner.seedOwner(owner, opts) + } + + /** Waits out an in-flight proof for the current generation; a generation + * advance past the entry point ends the wait (that proof is stale), and the + * wait is bounded — serialize latency must never inherit a slow RPC or + * process inspection; an unsettled proof just reads as no owner. */ + async settle(): Promise { + const target = this.scanner.generation + let deadlineHit = false + const deadline = setTimeout(() => { + deadlineHit = true + this.resolveSettleWaiters() + }, SETTLE_DEADLINE_MS) + deadline.unref?.() + try { + while ( + this.confirmInFlight && + this.scanner.generation <= target && + !this.disposed && + !deadlineHit + ) { + await new Promise((resolve) => this.settleWaiters.push(resolve)) + } + } finally { + clearTimeout(deadline) + } + } + + dispose(): void { + this.disposed = true + this.resolveSettleWaiters() + } + + private startConfirmation(generation: number): void { + this.latestCandidateGeneration = generation + if (this.confirmInFlight || this.disposed) { + return + } + this.confirmInFlight = true + const requested = generation + const attempt = ++this.confirmAttempt + // Why the deadline: a confirm that never settles (hung RPC) must not wedge + // ownership confirmation for the rest of the PTY's life; retire the attempt + // so a later candidate can start a fresh one. + const deadline = setTimeout( + () => this.retireConfirmAttempt(attempt, requested), + SETTLE_DEADLINE_MS + ) + deadline.unref?.() + // Why the guard: the callback is injected; a synchronous throw must not + // strand confirmInFlight. + let proof: Promise + try { + proof = this.confirm() + } catch { + proof = Promise.resolve(false) + } + void proof + .then((confirmed) => { + // A retired attempt's late verdict is inert: an unsettled proof already + // read as no owner, and flipping it afterwards would race fresh bytes. + if (attempt === this.confirmAttempt && confirmed && !this.disposed) { + this.scanner.trySetOwner(requested) + } + }) + .catch(() => {}) + .finally(() => { + clearTimeout(deadline) + this.retireConfirmAttempt(attempt, requested) + }) + } + + private retireConfirmAttempt(attempt: number, requested: number): void { + if (attempt !== this.confirmAttempt) { + return + } + this.confirmAttempt += 1 + this.confirmInFlight = false + const latest = this.latestCandidateGeneration + if ( + latest !== undefined && + latest !== requested && + latest === this.scanner.generation && + !this.disposed + ) { + // One superseding candidate can still be current; stale ones are not retried. + this.startConfirmation(latest) + } + this.resolveSettleWaiters() + } + + private resolveSettleWaiters(): void { + const waiters = this.settleWaiters + this.settleWaiters = [] + for (const waiter of waiters) { + waiter() + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index fcae3d02f6f..72c8939cb6c 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -162,6 +162,8 @@ export async function publishLegacyBinaryInitialSnapshot( truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, oscLinks: serialized?.oscLinks, + alternateScreen: serialized?.alternateScreen, + terminalOwner: serialized?.terminalOwner, data: serialized?.data ?? '' }) console.log('[mobile-terminal-stream] snapshot', { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts index a469b2d7a00..ac9e69dd0fc 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts @@ -76,6 +76,8 @@ export function installMultiplexFlowControl( seq: serialized.seq, source: serialized.source, kittyKeyboardFlags: serialized.kittyKeyboardFlags, + alternateScreen: serialized.alternateScreen, + terminalOwner: serialized.terminalOwner, truncatedByByteBudget: serialized.truncatedByByteBudget, data: serialized.data } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index 724e14f2cc5..6f1c2f8fc47 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -95,6 +95,8 @@ export async function publishMultiplexInitialSnapshot( truncatedByByteBudget: serialized?.truncatedByByteBudget, source: serialized?.source, kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + alternateScreen: serialized?.alternateScreen, + terminalOwner: serialized?.terminalOwner, oscLinks: serialized?.oscLinks, pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts index 7a4ae50e701..1020b2542d4 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -235,6 +235,8 @@ export function installMultiplexSlotFrames( cwd: serialized?.cwd, source: serialized?.source, kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + alternateScreen: serialized?.alternateScreen, + terminalOwner: serialized?.terminalOwner, oscLinks: serialized?.oscLinks, pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts index 33b09a0ead9..39f4465b8e3 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts @@ -79,6 +79,17 @@ export function sendSnapshotFrames( ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags: options.kittyKeyboardFlags } : {}), + ...(typeof options.seq === 'number' && options.terminalOwner + ? { terminalOwner: options.terminalOwner } + : {}), + // The terminalOwner conjunct is load-bearing, not redundant: no consumer + // re-checks it, and an un-gated alternateScreen would flip the renderer's + // mouse-reset selection on every alt-screen reattach of a live TUI. + ...(typeof options.seq === 'number' && + options.terminalOwner && + options.alternateScreen !== undefined + ? { alternateScreen: options.alternateScreen } + : {}), truncated: options.truncated === true, truncatedByByteBudget: options.truncatedByByteBudget === true }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts index 484a9f0f933..121cf9a7adb 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -27,6 +27,8 @@ export type SnapshotFrameOptions = { pendingEscapeTailAnsi?: string /** Effective kitty flags proven at this frame's own `seq`. */ kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } export type SerializedSnapshot = { @@ -42,6 +44,8 @@ export type SerializedSnapshot = { truncatedByByteBudget: boolean pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } | null export type TerminalViewportClient = { diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 88125c840b3..4b9afed46c6 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -47,8 +47,11 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { data: 'user@host:~$ ', cols: 80, rows: 24, + seq: 7, // The dangling partial the emulator could not serialize. - pendingEscapeTailAnsi: '\x1b[3' + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' }), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), @@ -119,7 +122,9 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { .map((frame) => decodeTerminalStreamFrame(frame)) .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)! expect(decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({ - pendingEscapeTailAnsi: '\x1b[3' + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' }) runtime.cleanupSubscription('terminal-multiplex:conn-1') diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index f7381c5665c..b6c53668f2d 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -595,6 +595,7 @@ describe('terminal subscribe buffering', () => { endCol: number uri: string }[] + terminalOwner?: 'shell' }) => void)[] = [] const serializeTerminalBuffer = vi .fn() @@ -611,6 +612,7 @@ describe('terminal subscribe buffering', () => { endCol: number uri: string }[] + terminalOwner?: 'shell' }>((resolve) => { restreamResolves.push(resolve) }) @@ -682,7 +684,8 @@ describe('terminal subscribe buffering', () => { data: 'newer', cols: 100, rows: 24, - oscLinks: newerOscLinks + oscLinks: newerOscLinks, + terminalOwner: 'shell' }) await vi.waitFor(() => expect( @@ -711,6 +714,9 @@ describe('terminal subscribe buffering', () => { kind: 'resized', oscLinks: newerOscLinks }) + expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).not.toHaveProperty( + 'terminalOwner' + ) runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index d69d906047d..fc558b60d74 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -57,6 +57,7 @@ export type PtyApi = { snapshotFrameAnsi?: string snapshotFrameRestoreAnsi?: string snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' snapshotSeq?: number isReattach?: boolean isAlternateScreen?: boolean @@ -143,6 +144,7 @@ export type PtyApi = { /** Effective kitty flags the snapshot owner proved at `seq`. Absent means * unknown; consumers must not turn that into a known `0`. */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ pendingPtyCount: number diff --git a/src/preload/index.ts b/src/preload/index.ts index 9efc4716019..d36b385d10a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1057,6 +1057,7 @@ const api = { snapshotFrameAnsi?: string snapshotFrameRestoreAnsi?: string snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' snapshotSeq?: number isReattach?: boolean isAlternateScreen?: boolean @@ -1179,6 +1180,7 @@ const api = { scrollbackAnsi?: string pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), getRendererDeliveryDebugSnapshot: (): Promise<{ diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts index 3f3756c0716..3dda2f83fec 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts @@ -25,6 +25,9 @@ export function projectIpcPtyConnectResult( ...(spawnResult.snapshotFrameRestoreAnsi !== undefined ? { snapshotFrameRestoreAnsi: spawnResult.snapshotFrameRestoreAnsi } : {}), + ...(spawnResult.snapshotTerminalOwner + ? { snapshotTerminalOwner: spawnResult.snapshotTerminalOwner } + : {}), isAlternateScreen: spawnResult.isAlternateScreen, sessionExpired: spawnResult.sessionExpired, coldRestore: spawnResult.coldRestore, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts index 0e130d0b00e..2a20be95575 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts @@ -1,6 +1,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_GRAPHIC_RENDITION @@ -220,6 +221,42 @@ describe('connectPanePty', () => { } }) + it('returns a shell-owned daemon snapshot to the normal buffer on reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: '\x1b[?1049h\x1b[?1003hSTALE-TUI', + isAlternateScreen: true, + snapshotTerminalOwner: 'shell' + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + }) + it('drops a too-wide daemon alt frame and keeps the scrollback prefix', async () => { // Why: fixed-grid alt rows clip at a narrower viewport until the owner repaints. const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts index 905ec2bf687..fe18699c20d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts @@ -1,8 +1,10 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, - POST_REPLAY_LIVE_SNAPSHOT_RESET + POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_REATTACH_RESET } from '../../../../shared/terminal-mode-reset-profiles' import { makePaneKey } from '../../../../shared/stable-pane-id' import { flushAsyncTicks } from './pty-connection-test-async' @@ -481,7 +483,8 @@ describe('connectPanePty', () => { getMainBufferSnapshot.mockResolvedValue({ data: 'agent-frame\x1b[?25l', cols: 100, - rows: 30 + rows: 30, + alternateScreen: true }) const paneKey = makePaneKey('tab-1', LEAF_1) const now = Date.now() @@ -524,12 +527,75 @@ describe('connectPanePty', () => { POST_REPLAY_LIVE_SNAPSHOT_RESET, expect.any(Function) ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) } finally { binding.dispose() } }) - it('resets cursor and focus modes on hidden-to-visible snapshot restore without a live agent', async () => { + it('lets fresh host shell proof outrank stale live-agent metadata on reveal', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'stale-agent-frame\x1b[?25l', + cols: 100, + rows: 30, + alternateScreen: true, + terminalOwner: 'shell' + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: '', + agentType: 'codex', + paneKey, + terminalTitle: 'codex', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + + const isVisibleRef = { current: false } + const pane = createPane(1) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ isVisibleRef, startup: { command: 'codex' } }) as never + ) + try { + await flushAsyncTicks(6) + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('preserves current behavior when a snapshot has no host ownership proof', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -544,7 +610,8 @@ describe('connectPanePty', () => { getMainBufferSnapshot.mockResolvedValue({ data: 'shell-frame\x1b[?25l', cols: 100, - rows: 30 + rows: 30, + alternateScreen: false }) const isVisibleRef = { current: false } @@ -570,6 +637,116 @@ describe('connectPanePty', () => { POST_REPLAY_LIVE_SNAPSHOT_RESET, expect.any(Function) ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('keeps a live alternate-screen pane interactive when a legacy snapshot omits its mode flag', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'legacy-tui-frame', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + pane.terminal.buffer.active.type = 'alternate' + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_SNAPSHOT_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('grounds a stale alternate-screen snapshot when the host proves shell ownership', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'stale-tui-frame', + cols: 100, + rows: 30, + alternateScreen: true, + terminalOwner: 'shell' + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + pane.terminal.buffer.active.type = 'alternate' + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ isVisibleRef, startup: { command: 'codex' } }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_SNAPSHOT_RESET, + expect.any(Function) + ) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() } finally { binding.dispose() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts index bf4d4a34feb..99c54dad2eb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts @@ -2,6 +2,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, @@ -380,6 +381,45 @@ describe('connectPanePty', () => { }) }) + it('lets fresh host shell proof outrank stale live-agent metadata on reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: '\x1b[?1049h\x1b[?1003hstale agent snapshot', + isAlternateScreen: true, + snapshotTerminalOwner: 'shell' as const + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const manager = createManager(1) + connectPanePty( + pane as never, + manager as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + it('ignores the stale agent signal on a cold restore and applies the fresh-shell reset', async () => { // Why: pane status and title are persisted, so after a cold restore they still // describe the process that died and make the pane look agent-owned. Preserving diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index ff7f5662b47..cb0806900af 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -92,7 +92,8 @@ export function createReattachPayloadHandlers( session.reattachReplayResetSequence( daemonSnapshotReplay, Boolean(ctx.connectResult.coldRestore), - ctx.connectResult.isAlternateScreen + ctx.connectResult.isAlternateScreen, + ctx.connectResult.snapshotTerminalOwner ) ) if (ctx.connectResult.pendingEscapeTailAnsi) { @@ -184,7 +185,8 @@ export function createReattachPayloadHandlers( session.reattachReplayResetSequence( modelData, Boolean(ctx.connectResult?.coldRestore), - modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen + modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen, + modelSnapshot.terminalOwner ) ) if (modelSnapshot.pendingEscapeTailAnsi) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 192dd042a84..28e0f95b9f7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -154,7 +154,9 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio kittyKeyboardFlags: meta.kittyKeyboardFlags, snapshotSeq: meta.snapshotSeq } - : {}) + : {}), + ...(meta.terminalOwner ? { terminalOwner: meta.terminalOwner } : {}), + ...(meta.alternateScreen !== undefined ? { alternateScreen: meta.alternateScreen } : {}) } session.scheduleReplayDataDrain() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts index e29842991c4..9784df28161 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts @@ -2,6 +2,7 @@ import { replayIntoTerminal, replayIntoTerminalAsync } from '../replay-guard' import { terminalOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script' import { buildPostReplayLiveAgentReattachReset, + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE @@ -133,7 +134,8 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void session.reattachReplayResetSequence = ( payload: string, ownerProcessEnded = false, - isAlternateScreen?: boolean + isAlternateScreen?: boolean, + terminalOwner?: 'shell' ): string => { // Why a cold restore overrides the agent signal: liveness is read from the // pane's status and title, both of which are persisted, so after a cold @@ -143,6 +145,11 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void if (ownerProcessEnded) { return POST_REPLAY_MODE_RESET } + if (terminalOwner === 'shell') { + return (isAlternateScreen ?? session.kittyKeyboardModes.isAlternateScreen) + ? POST_REPLAY_DEAD_TUI_RESET + : POST_REPLAY_REATTACH_RESET + } if (session.shouldPreserveAgentReattachModes()) { return buildPostReplayLiveAgentReattachReset(payload) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index 076c9194934..3d88cc34fa7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -4,6 +4,8 @@ import { waitForTerminalReplayWritesParsed } from '../replay-guard' import { POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_DEAD_TUI_RESET, + POST_REPLAY_REATTACH_RESET, RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles' import { @@ -51,6 +53,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): scrollbackAnsi?: string pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' }): Promise { const restorePtyId = session.transport.getPtyId() const restoreGeneration = session.hiddenOutputRestoreGeneration @@ -140,12 +143,20 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): session.writeReplayData(replayChunk) } } - // Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup). - session.writeReplayData( - session.hasLiveAgentReattachStatusOrTitleSignal() - ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET - : POST_REPLAY_LIVE_SNAPSHOT_RESET - ) + const hasLiveAgent = session.hasLiveAgentReattachStatusOrTitleSignal() + // Why the pane-local fallback: every owner-publishing host also fills + // alternateScreen, but the reattach site falls back to pane state for + // an absent flag and this site must not drift from it (?1049l on a + // normal-buffer pane still runs cursor restore). + const postReplayReset = + snapshot.terminalOwner === 'shell' + ? (snapshot.alternateScreen ?? session.isPaneOnAlternateScreen()) + ? POST_REPLAY_DEAD_TUI_RESET + : POST_REPLAY_REATTACH_RESET + : hasLiveAgent + ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET + : POST_REPLAY_LIVE_SNAPSHOT_RESET + session.writeReplayData(postReplayReset) if (snapshot.pendingEscapeTailAnsi) { // Why last: snapshot taken mid-escape; re-arm as the FINAL replay write (any later ESC aborts it) so the live tail completes it, not render literally (Bug E / #7329). session.writeReplayData(snapshot.pendingEscapeTailAnsi) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index 33ec09d79f6..87a2b6039ce 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -86,7 +86,8 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { return false } const payload = session.pendingReplayData - const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload + const { data, clearBeforeReplay, pendingEscapeTailAnsi, alternateScreen, terminalOwner } = + payload session.pendingReplayData = null const isCurrentPayload = (): boolean => !session.disposed && @@ -120,7 +121,9 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { continue } if (clearBeforeReplay || data.length > 0) { - await session.writeReplayDataAsync(session.reattachReplayResetSequence(data)) + await session.writeReplayDataAsync( + session.reattachReplayResetSequence(data, false, alternateScreen, terminalOwner) + ) if (!isCurrentPayload()) { continue } diff --git a/src/renderer/src/components/terminal-pane/pty-output-processor.ts b/src/renderer/src/components/terminal-pane/pty-output-processor.ts index 378e72a56c9..ac611e8ed0c 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-processor.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-processor.ts @@ -35,6 +35,8 @@ export type ProcessPtyOutputOptions = { pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number snapshotSeq?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } function removeSuppressedCursorNativeTitles( @@ -211,7 +213,11 @@ export function createPtyOutputProcessor({ ...(options.kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags: options.kittyKeyboardFlags } : {}), - ...(options.snapshotSeq !== undefined ? { snapshotSeq: options.snapshotSeq } : {}) + ...(options.snapshotSeq !== undefined ? { snapshotSeq: options.snapshotSeq } : {}), + ...(options.alternateScreen !== undefined + ? { alternateScreen: options.alternateScreen } + : {}), + ...(options.terminalOwner ? { terminalOwner: options.terminalOwner } : {}) } if (Object.keys(replayMeta).length > 0) { callbacks.onReplayData(data, replayMeta) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 17f390f4ec4..7db2a6e01f0 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -44,6 +44,7 @@ export type PtyBufferSnapshot = { /** Effective kitty flags the owner of this image proved at `seq`. Absent * means unknown; never rewrite that silence into a known `0`. */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } /** Metadata for one authoritative replay payload. */ @@ -56,6 +57,8 @@ export type PtyReplayDataMeta = { /** The boundary `kittyKeyboardFlags` describes, recorded as the renderer's * ordered high-water so a quiet pane can still publish a coherent snapshot. */ snapshotSeq?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } export type LocalPtySessionMetadata = { @@ -86,6 +89,7 @@ export type PtyConnectResult = { * domain `snapshotSeq` main reconciled for the same attach boundary. Absent * means unknown, never a proven inactive protocol. */ snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' snapshotSeq?: number isAlternateScreen?: boolean sessionExpired?: boolean diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts index 76f234c909c..709e1aa4875 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts @@ -97,7 +97,9 @@ describe('remote transport snapshot escape-tail threading (#7329)', () => { rows: 24, seq: 7, source: 'headless', - pendingEscapeTailAnsi: '\x1b[3' + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' }) }), encodeTerminalStreamFrame({ @@ -120,7 +122,11 @@ describe('remote transport snapshot escape-tail threading (#7329)', () => { await expect.poll(() => onReplayData.mock.calls.length, { timeout: 5000 }).toBeGreaterThan(0) expect(onReplayData).toHaveBeenCalledWith( 'user@host:~$ ', - expect.objectContaining({ pendingEscapeTailAnsi: '\x1b[3' }) + expect.objectContaining({ + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' + }) ) }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 5e0e42715ca..16c6b235eac 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1823,6 +1823,12 @@ export function createRemoteRuntimePtyTransport( kittyKeyboardFlags: meta.kittyKeyboardFlags, snapshotSeq: meta.seq } + : {}), + ...(meta?.terminalOwner && meta.seq !== undefined + ? { terminalOwner: meta.terminalOwner } + : {}), + ...(meta?.alternateScreen !== undefined && meta.seq !== undefined + ? { alternateScreen: meta.alternateScreen } : {}) }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index 3072b98dad2..ce6a3db257c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -311,4 +311,34 @@ describe('terminal replay state reset', () => { term.dispose() } }) + + it('disarms mouse input when a hidden replay lands on the normal shell buffer', async () => { + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await writeTerminal(term, '\x1b[?1003h\x1b[?1006h\x1b[?1049hTUI FRAME\x1b[?1049lSHELL MARKER') + expect(term.buffer.active.type).toBe('normal') + expect(term.modes.mouseTrackingMode).toBe('any') + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(term.buffer.active.type).toBe('normal') + expect(term.modes.mouseTrackingMode).toBe('none') + expect(term.buffer.active.getLine(0)?.translateToString()).toContain('SHELL MARKER') + } finally { + term.dispose() + } + }) + + it('preserves mouse input for a live alternate-screen replay', async () => { + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await writeTerminal(term, '\x1b[?1003h\x1b[?1006h\x1b[?1049hLIVE TUI') + await writeTerminal(term, POST_REPLAY_LIVE_SNAPSHOT_RESET) + + expect(term.buffer.active.type).toBe('alternate') + expect(term.modes.mouseTrackingMode).toBe('any') + } finally { + term.dispose() + } + }) }) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index d9783f85d40..b02f5b63870 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -67,6 +67,8 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { pendingEscapeTailAnsi?: string seq?: number kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } ) => void onSubscribed?: () => void @@ -96,6 +98,8 @@ export type RemoteRuntimeSnapshotImage = { * from any host that predates the field — the pane tracker then stays * unproven and commits raw text instead of guessing zero. */ kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } /** Transient causes the host itself reported: a request reached it and it declined to serialize now. */ @@ -210,6 +214,8 @@ type RemoteRuntimeSnapshotInfo = { seq?: number source?: 'headless' | 'renderer' kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' requestId?: number truncated?: boolean unavailable?: TerminalSnapshotUnavailableReason @@ -925,6 +931,8 @@ class RemoteRuntimeTerminalMultiplexer { seq: info?.seq, source: info?.source, kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner, pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi } }) @@ -933,7 +941,9 @@ class RemoteRuntimeTerminalMultiplexer { stream.callbacks.onSnapshot(data ?? '', { pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, seq: info?.seq, - kittyKeyboardFlags: info?.kittyKeyboardFlags + kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner }) } else if (target === 'recovery') { // Why: a server-pushed recovery snapshot replaces terminal state @@ -943,7 +953,9 @@ class RemoteRuntimeTerminalMultiplexer { stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, { pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, seq: info?.seq, - kittyKeyboardFlags: info?.kittyKeyboardFlags + kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner }) } } else if (matchesPendingRequest) { @@ -1600,6 +1612,8 @@ function decodeSnapshotInfo( unavailable?: unknown pendingEscapeTailAnsi?: unknown kittyKeyboardFlags?: unknown + alternateScreen?: unknown + terminalOwner?: unknown }>(payload) if (!raw) { return null @@ -1611,6 +1625,11 @@ function decodeSnapshotInfo( source: raw.source === 'headless' || raw.source === 'renderer' ? raw.source : undefined, // Negative, fractional, and unsafe values are treated as absent, never clamped. kittyKeyboardFlags: parseTerminalKittyKeyboardFlags(raw.kittyKeyboardFlags), + alternateScreen: + raw.terminalOwner === 'shell' && typeof raw.alternateScreen === 'boolean' + ? raw.alternateScreen + : undefined, + terminalOwner: raw.terminalOwner === 'shell' ? raw.terminalOwner : undefined, requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined, truncated: raw.truncated === true, unavailable: parseTerminalSnapshotUnavailableReason(raw.unavailable), diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index 01a7f62acc3..69f176b9d80 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -23,6 +23,10 @@ export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x // Why: the normal-buffer fallback can follow a dead TUI, so its stale pen and saved pen must not reach the surviving shell. export const POST_REPLAY_REATTACH_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l${SAVE_GROUNDED_CURSOR}` +// Why: a foreground shell proves an alternate-screen owner died without its +// ?1049l cleanup; leave the renderer on the shell's normal buffer as well. +export const POST_REPLAY_DEAD_TUI_RESET = `\x1b[?1049l${POST_REPLAY_REATTACH_RESET}` + // Why: an alt-screen reattach replays the daemon's rehydrateSequences, which re-arm the live TUI's // mouse modes; wiping them one write later hands drags back to xterm's row selection (#8291). // Normal-buffer panes keep RESET_MOUSE_REPORTING so a dead TUI's stale modes never reach a shell (#7893). diff --git a/src/shared/terminal-owner.ts b/src/shared/terminal-owner.ts new file mode 100644 index 00000000000..8797e0dec3f --- /dev/null +++ b/src/shared/terminal-owner.ts @@ -0,0 +1,5 @@ +export type TerminalOwner = 'shell' + +export function parseTerminalOwner(value: unknown): TerminalOwner | undefined { + return value === 'shell' ? value : undefined +} diff --git a/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts index 1eba41b7fef..54d303ac871 100644 --- a/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts @@ -120,6 +120,11 @@ describe('cross-version remote terminal wire', () => { const record = await runTerminalSkewJourney({ hostBuild: current, clientBuild: current }) expectJourneyActuallyRan(record) expectWireCompatible(record) + expect(record.snapshotStarts).toEqual([ + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }) + ]) }, SUITE_TIMEOUT_MS ) @@ -131,6 +136,11 @@ describe('cross-version remote terminal wire', () => { expect(record.clientRevision).toBe(baseline.revision) expectJourneyActuallyRan(record) expectWireCompatible(record) + expect(record.snapshotStarts).toEqual([ + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }) + ]) }, SUITE_TIMEOUT_MS ) @@ -142,6 +152,10 @@ describe('cross-version remote terminal wire', () => { expect(record.hostRevision).toBe(baseline.revision) expectJourneyActuallyRan(record) expectWireCompatible(record) + for (const start of record.snapshotStarts) { + expect(start).not.toHaveProperty('terminalOwner') + expect(start).not.toHaveProperty('alternateScreen') + } }, SUITE_TIMEOUT_MS ) diff --git a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts index 1fc471b0de7..528e1a85874 100644 --- a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts +++ b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts @@ -89,9 +89,19 @@ export function createHostTerminalRuntimeStub( rows: number seq: number source: 'headless' + alternateScreen: false + terminalOwner: 'shell' }> => { stub.serializeCount++ - return { data: stub.buffer, cols, rows, seq: outputSequence, source: 'headless' } + return { + data: stub.buffer, + cols, + rows, + seq: outputSequence, + source: 'headless', + alternateScreen: false, + terminalOwner: 'shell' + } } const runtime: Record = { diff --git a/tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts b/tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts new file mode 100644 index 00000000000..c4cfc10a8ec --- /dev/null +++ b/tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts @@ -0,0 +1,220 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + execInTerminal, + getTerminalContent, + readPaneIdentitySnapshot, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + clearTerminalPtyWriteLog, + installTerminalPtyWriteSpy, + readTerminalPtyWriteEntries +} from './helpers/terminal-pty-write-spy' +import { stageNodeScriptForTerminal } from './helpers/run-node-script-in-terminal' + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveTab(id) + state?.setActiveTabType('terminal') + }, tabId) + await expect + .poll(() => + page.locator('[data-testid="sortable-tab"][data-active="true"]').getAttribute('data-tab-id') + ) + .toBe(tabId) +} + +for (const exitMode of ['normal', 'sigkill'] as const) { + test(`clears modes when a child TUI exits via ${exitMode} while its shell survives hidden`, async ({ + electronApp, + orcaPage + }) => { + await installTerminalPtyWriteSpy(electronApp) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const child = [ + "process.stdin.setRawMode?.(true); process.stdin.resume(); process.stdout.write('\\x1b[?1049h\\x1b[?1003h\\x1b[?1006h\\x1b[?25lCHILD_TUI_STARTED\\r\\n')", + ...(exitMode === 'normal' + ? [ + "process.on('SIGTERM', () => { process.stdout.write('\\x1b[?1006l\\x1b[?1003l\\x1b[?1049l\\x1b[?25h'); process.exit(0) })" + ] + : []), + "setInterval(() => process.stdout.write('\\x1b[2J\\x1b[HCHILD_TUI_FRAME\\r\\n'), 80)" + ].join(';') + const parent = [ + `const {spawn}=require('node:child_process'); const child=spawn(process.execPath,['-e',${JSON.stringify(child)}],{stdio:'inherit'});`, + "process.stdout.write('CHILD_TUI_PID_' + child.pid + '\\r\\n');", + "child.on('exit', () => process.stdout.write('\\r\\nCHILD_TUI_KILLED\\r\\n'))" + ].join(' ') + const command = stageNodeScriptForTerminal(parent, { prefix: 'orca-child-tui-kill' }).command + const tuiTabId = await orcaPage.evaluate( + ({ command }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (!state || !worktreeId) { + throw new Error('store/worktree unavailable') + } + const tab = state.createTab(worktreeId) + state.queueTabStartupCommand(tab.id, { command }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { command } + ) + + await expect.poll(() => getActiveTabId(orcaPage), { timeout: 5_000 }).toBe(tuiTabId) + const tuiPtyId = await waitForActivePanePtyId(orcaPage, 30_000) + const tuiIdentity = await readPaneIdentitySnapshot(orcaPage) + expect(tuiIdentity?.activeLeafId).not.toBeNull() + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 20_000 }) + .toContain('CHILD_TUI_FRAME') + await expect + .poll(async () => { + const match = (await getTerminalContent(orcaPage, 6_000)).match(/CHILD_TUI_PID_(\d+)/) + return match?.[1] ?? null + }) + .not.toBeNull() + const childPid = (await getTerminalContent(orcaPage, 6_000)).match(/CHILD_TUI_PID_(\d+)/)?.[1] + expect(childPid).toBeDefined() + await activateTerminalTab(orcaPage, shellTabId) + const shellPtyId = await waitForActivePanePtyId(orcaPage, 30_000) + const exitSignal = exitMode === 'normal' ? 'SIGTERM' : 'SIGKILL' + const killCommand = stageNodeScriptForTerminal(`process.kill(${childPid!}, '${exitSignal}')`, { + prefix: 'orca-child-tui-external-kill' + }).command + await execInTerminal(orcaPage, shellPtyId, killCommand) + await expect + .poll( + () => + orcaPage.evaluate(async (ptyId) => { + const processName = await window.api.pty.getForegroundProcess(ptyId) + return processName?.toLowerCase() ?? null + }, tuiPtyId), + { timeout: 8_000 } + ) + .toMatch(/^(bash|zsh|sh|fish)(\.exe)?$/) + await orcaPage.evaluate( + ({ paneKey, tabId }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (!state || !worktreeId) { + throw new Error('store/worktree unavailable') + } + state.setAgentStatus( + paneKey, + { state: 'working', prompt: 'stale hidden TUI status', agentType: 'codex' }, + 'Codex', + undefined, + { tabId, worktreeId } + ) + }, + { paneKey: `${tuiTabId}:${tuiIdentity!.activeLeafId!}`, tabId: tuiTabId } + ) + await clearTerminalPtyWriteLog(electronApp) + await activateTerminalTab(orcaPage, tuiTabId) + + const revealedPtyId = await orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return manager?.getActivePane?.()?.container?.dataset?.ptyId ?? null + }, tuiTabId) + expect(revealedPtyId).not.toBeNull() + await expect + .poll(async () => { + const snapshot = await orcaPage.evaluate( + (ptyId) => window.api.pty.getMainBufferSnapshot(ptyId, { scrollbackRows: 5000 }), + revealedPtyId! + ) + return snapshot?.terminalOwner === 'shell' && snapshot.alternateScreen === false + }) + .toBe(true) + await expect + .poll(() => + orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + return { + buffer: pane?.terminal.buffer.active.type, + mouse: pane?.terminal.modes.mouseTrackingMode + } + }, tuiTabId) + ) + .toEqual({ buffer: 'normal', mouse: 'none' }) + + // Why normal-exit only: the barrier preserves shell output from the 133;D + // boundary onward (the prompt), but bytes the dying command printed while + // the alternate screen was still up are part of the discarded dead frame. + // On SIGKILL the parent's exit line lands pre-D in the alt buffer, so it is + // unrecoverable by design; on normal exit the child's own ?1049l precedes + // it and it survives on the normal buffer. + if (exitMode === 'normal') { + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 8_000 }) + .toContain('CHILD_TUI_KILLED') + } + + const shellInputMarker = 'SHELL_INPUT_AFTER_TUI_KILL' + await execInTerminal(orcaPage, revealedPtyId!, `printf ${shellInputMarker}`) + await expect + .poll(async () => { + const writes = await readTerminalPtyWriteEntries(electronApp) + return writes.some( + (entry) => entry.id === revealedPtyId && entry.data.includes(shellInputMarker) + ) + }) + .toBe(true) + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 8_000 }) + .toContain(shellInputMarker) + + const terminalScreen = orcaPage.locator(`[data-pty-id="${revealedPtyId}"] .xterm-screen`) + await terminalScreen.hover({ position: { x: 20, y: 20 } }) + await orcaPage.mouse.wheel(0, 120) + await orcaPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) + + const ptyWrites = (await readTerminalPtyWriteEntries(electronApp)) + .filter((entry) => entry.id === revealedPtyId) + .map((entry) => entry.data) + const escape = String.fromCharCode(27) + expect(ptyWrites.some((data) => data.includes(`${escape}[<`))).toBe(false) + expect(ptyWrites.some((data) => data.includes(`${escape}[M`))).toBe(false) + + const terminalState = await orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + return { + buffer: pane?.terminal.buffer.active.type, + mouse: pane?.terminal.modes.mouseTrackingMode + } + }, tuiTabId) + expect(terminalState).toEqual({ buffer: 'normal', mouse: 'none' }) + expect(await getTerminalContent(orcaPage, 6_000)).not.toMatch(/\[<\d+;\d+;\d+[Mm]/) + + await activateTerminalTab(orcaPage, shellTabId) + expect(await waitForActivePanePtyId(orcaPage, 30_000)).toBe(shellPtyId) + const unrelatedMarker = 'UNRELATED_SHELL_STILL_LIVE' + await execInTerminal(orcaPage, shellPtyId, `printf ${unrelatedMarker}`) + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 8_000 }) + .toContain(unrelatedMarker) + }) +} From ebcd637db981b0ce272c8e183794a8fc7caaa7be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:22:05 -0700 Subject: [PATCH 004/118] fix(codex): launch WSL accounts from direct homes (#16504) * fix(codex): launch WSL accounts from direct homes * fix(codex): coalesce WSL auth drains and validate distro homes * fix(codex): preserve legacy WSL account home metadata * fix(codex): retain marked WSL home compatibility * fix(codex): verify the bytes the WSL drain promotes, not an earlier read The apply script validated the source hash and then re-read it with cp, so a legacy pane rotating in that window put bytes freshness never judged over a valid account home. Codex rewrites auth.json in place, so that read can be torn. Covers it by running the real guest script under sh with a sha256sum shim that rotates the source between the two reads; without the guard it exits 0. * fix(codex): harden WSL auth drain races --- ...sl-runtime-auth-drain-apply-script.test.ts | 170 ++++++++++ .../legacy-wsl-runtime-auth-drain.test.ts | 212 ++++++++++++ .../legacy-wsl-runtime-auth-drain.ts | 315 ++++++++++++++++++ .../codex-accounts/runtime-home-service.ts | 197 +++++++++-- .../runtime-home-wsl-managed-accounts.test.ts | 71 ++-- .../runtime-home-wsl-session-bridge.test.ts | 62 ++-- .../runtime-home-wsl-system-default.test.ts | 37 +- .../wsl-codex-auth-batch-reader.ts | 70 ++++ src/main/codex/codex-pane-account-registry.ts | 9 + .../codex/codex-pane-launch-account.test.ts | 2 +- src/main/codex/codex-pane-launch-account.ts | 17 +- .../codex/codex-stale-pane-accounts.test.ts | 31 ++ 12 files changed, 1043 insertions(+), 150 deletions(-) create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts create mode 100644 src/main/codex-accounts/wsl-codex-auth-batch-reader.ts diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts new file mode 100644 index 00000000000..44ce6a6e00f --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts @@ -0,0 +1,170 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { _internals } from './legacy-wsl-runtime-auth-drain' + +const isWindows = process.platform === 'win32' + +const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' +const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n' +const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' +// Codex truncates before it writes, so a read landing mid-rotation sees this. +const TORN_AUTH = '{"tokens":{"exp' +const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n' +const TORN_CREDENTIALS = '{"server":' + +function sha256(contents: string): string { + return createHash('sha256').update(contents).digest('hex') +} + +/** + * Runs the real guest script under `sh`, with `sha256sum` shimmed so a chosen + * hash call can rewrite the source underneath the script. That is the only way + * to land Codex's in-place rotation inside the window the script itself opens. + */ +function runApplyScript( + options: { + rewriteSourceAfterHashCall?: number + rewriteBytes?: string + rewriteTarget?: 'source-auth' | 'source-credentials' | 'target-auth' + sourceCredentials?: string + } = {} +): { + legacyAuth: string + status: number + targetAuth: string + targetCredentials: string | null +} { + const root = mkdtempSync(join(tmpdir(), 'orca-drain-apply-')) + const legacyHome = join(root, 'legacy') + const targetHome = join(root, 'account') + const binDir = join(root, 'bin') + for (const dir of [legacyHome, targetHome, binDir]) { + mkdirSync(dir, { recursive: true }) + } + const legacyAuthPath = join(legacyHome, 'auth.json') + const targetAuthPath = join(targetHome, 'auth.json') + const legacyCredentialsPath = join(legacyHome, '.credentials.json') + const targetCredentialsPath = join(targetHome, '.credentials.json') + writeFileSync(legacyAuthPath, SOURCE_AUTH) + writeFileSync(targetAuthPath, TARGET_AUTH) + if (options.sourceCredentials !== undefined) { + writeFileSync(legacyCredentialsPath, options.sourceCredentials) + } + + const counterPath = join(root, 'hash-calls') + writeFileSync(counterPath, '0') + const shimPath = join(binDir, 'sha256sum') + writeFileSync( + shimPath, + `#!/usr/bin/env node +const { createHash } = require('node:crypto') +const fs = require('node:fs') +const file = process.argv[process.argv.length - 1] +process.stdout.write( + createHash('sha256').update(fs.readFileSync(file)).digest('hex') + ' ' + file + '\\n' +) +const calls = Number(fs.readFileSync(process.env.HASH_COUNTER, 'utf8')) + 1 +fs.writeFileSync(process.env.HASH_COUNTER, String(calls)) +if (process.env.REWRITE_AFTER && calls === Number(process.env.REWRITE_AFTER)) { + fs.writeFileSync(process.env.REWRITE_TARGET, process.env.REWRITE_BYTES) +} +` + ) + chmodSync(shimPath, 0o755) + + let status = 0 + try { + execFileSync( + '/bin/sh', + [ + '-c', + _internals.applyLegacyAuthScript, + 'sh', + legacyHome, + join(root, 'absent-active-home'), + join(root, 'absent-marker.json'), + targetHome, + sha256(SOURCE_AUTH), + sha256(TARGET_AUTH), + '1', + '0', + options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials) + ], + { + encoding: 'utf8', + env: { + ...process.env, + HASH_COUNTER: counterPath, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + REWRITE_AFTER: options.rewriteSourceAfterHashCall + ? String(options.rewriteSourceAfterHashCall) + : '', + REWRITE_BYTES: options.rewriteBytes ?? TORN_AUTH, + REWRITE_TARGET: + options.rewriteTarget === 'source-credentials' + ? legacyCredentialsPath + : options.rewriteTarget === 'target-auth' + ? targetAuthPath + : legacyAuthPath + }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 20_000 + } + ) + } catch (error) { + status = (error as { status?: number }).status ?? -1 + } + return { + legacyAuth: readFileSync(legacyAuthPath, 'utf8'), + status, + targetAuth: readFileSync(targetAuthPath, 'utf8'), + targetCredentials: existsSync(targetCredentialsPath) + ? readFileSync(targetCredentialsPath, 'utf8') + : null + } +} + +describe.skipIf(isWindows)('legacy WSL auth drain apply script', () => { + it('promotes the validated source into the account home', () => { + const outcome = runApplyScript() + expect(outcome.targetAuth).toBe(SOURCE_AUTH) + }) + + it('leaves the legacy home untouched while promoting', () => { + // One-directional: the promote step must never write back to the old home. + expect(runApplyScript().legacyAuth).toBe(SOURCE_AUTH) + }) + + it('refuses torn bytes and leaves the account home intact', () => { + // Hash call 1 is the source pre-check; rotating right after it means `cp` + // reads bytes freshness never judged. Pre-guard, those reached the target. + const outcome = runApplyScript({ rewriteSourceAfterHashCall: 1 }) + expect(outcome.status).toBe(42) + expect(outcome.targetAuth).toBe(TARGET_AUTH) + }) + + it('refuses MCP credentials that changed after host validation', () => { + const outcome = runApplyScript({ + rewriteSourceAfterHashCall: 2, + rewriteBytes: TORN_CREDENTIALS, + rewriteTarget: 'source-credentials', + sourceCredentials: SOURCE_CREDENTIALS + }) + expect(outcome.status).toBe(43) + expect(outcome.targetCredentials).toBeNull() + }) + + it('does not overwrite auth changed after the destination hash check', () => { + const outcome = runApplyScript({ + rewriteBytes: NEWER_AUTH, + rewriteSourceAfterHashCall: 4, + rewriteTarget: 'target-auth' + }) + expect(outcome.status).toBe(39) + expect(outcome.targetAuth).toBe(NEWER_AUTH) + }) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts new file mode 100644 index 00000000000..0c0caa1b669 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runWslProcessMock } = vi.hoisted(() => ({ + runWslProcessMock: vi.fn() +})) + +vi.mock('../wsl/wsl-runner', () => ({ + runWslProcess: runWslProcessMock +})) + +import { + _internals, + drainLegacyWslRuntimeAuth, + startLegacyWslRuntimeAuthDrain +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths } from './wsl-codex-auth-batch-reader' + +const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' +const STALE_AUTH = '{"tokens":{"expires_at":1000}}\n' +const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' + +function inspection(auth: string, credentials?: string): string { + return [ + Buffer.from(auth).toString('base64'), + credentials === undefined ? 'missing' : 'present', + credentials === undefined ? '' : Buffer.from(credentials).toString('base64') + ].join('\n') +} + +function result(code: number, stdout = '') { + return { + code, + stdout, + stderr: '', + timedOut: false, + environmentResolved: true + } +} + +describe('legacy WSL runtime auth drain', () => { + beforeEach(() => { + runWslProcessMock.mockReset() + _internals.resetDrainQueue() + }) + + it('promotes fresher auth guest-side while a legacy pane remains', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + .mockResolvedValueOnce(result(0)) + const resolveDestination = vi.fn(() => ({ + authContents: STALE_AUTH, + linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home' + })) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination + }) + + expect(resolveDestination).toHaveBeenCalledWith(SOURCE_AUTH) + expect(runWslProcessMock).toHaveBeenCalledTimes(2) + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(3)).toEqual([ + '/home/alice/.local/share/orca/codex-accounts/account-1/home', + expect.any(String), + expect.any(String), + '1', + '0', + 'missing' + ]) + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('readlink -f') + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('source_credentials=') + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('chmod 600') + }) + + it('does not write when freshness cannot be proven', async () => { + runWslProcessMock.mockResolvedValueOnce(result(0, inspection('{"tokens":{}}\n'))) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: '{"tokens":{}}\n', + linuxHomePath: '/home/alice/.codex' + }) + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('refuses a source with no unique destination', async () => { + runWslProcessMock.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => null + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('retires stale legacy auth only after the last recorded pane exits', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + .mockResolvedValueOnce(result(0)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: NEWER_AUTH, + linuxHomePath: '/home/alice/.codex' + }) + }) + + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-3)).toEqual(['0', '1', 'missing']) + }) + + it('keeps an absent source retryable while a legacy pane remains', async () => { + runWslProcessMock.mockResolvedValueOnce(result(21)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination: vi.fn() + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('does nothing after the guest-side completion marker is present', async () => { + runWslProcessMock.mockResolvedValueOnce(result(20)) + const resolveDestination = vi.fn() + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination + }) + + expect(resolveDestination).not.toHaveBeenCalled() + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('marks an absent source complete after every legacy pane exits', async () => { + runWslProcessMock.mockResolvedValueOnce(result(21)).mockResolvedValueOnce(result(0)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: vi.fn() + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(2) + expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([ + '/home/alice/.local/share/orca/codex-runtime-home/home', + '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home', + '/home/alice/.local/share/orca/codex-runtime-home/direct-home-auth-drain-v1.json' + ]) + }) + + it('coalesces concurrent drain triggers instead of queueing every poll', async () => { + runWslProcessMock.mockImplementation(() => new Promise(() => {})) + const options = { + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination: () => null + } + + startLegacyWslRuntimeAuthDrain(options) + startLegacyWslRuntimeAuthDrain(options) + await Promise.resolve() + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('reads every candidate home in one bounded guest process', async () => { + runWslProcessMock.mockResolvedValueOnce( + result( + 0, + [`present:${Buffer.from(SOURCE_AUTH).toString('base64')}`, 'missing', 'unreadable'].join( + '\n' + ) + ) + ) + + await expect( + readWslCodexAuths('Ubuntu', ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad']) + ).resolves.toEqual([ + { kind: 'present', contents: SOURCE_AUTH }, + { kind: 'missing' }, + { kind: 'unreadable' } + ]) + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + expect(runWslProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'], + maxOutputBytes: 2 * 1024 * 1024, + timeoutMs: 5_000 + }) + ) + }) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts new file mode 100644 index 00000000000..cc50be185af --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -0,0 +1,315 @@ +import { createHash } from 'node:crypto' +import { posix as pathPosix } from 'node:path' +import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' +import { runWslProcess } from '../wsl/wsl-runner' +import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity' +import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader' + +const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json' +const MARKER_PRESENT_EXIT = 20 +const SOURCE_AUTH_ABSENT_EXIT = 21 + +export type LegacyWslRuntimeAuthDestination = { + authContents: string + linuxHomePath: string +} + +type LegacyWslRuntimeInspection = { + authContents: string + credentials: { kind: 'missing' } | { kind: 'present'; contents: string } +} + +type LegacyWslRuntimeAuthDrainOptions = { + distro: string + guestHomeLinuxPath: string + legacyPanePresent: boolean + resolveDestination: ( + runtimeAuthContents: string + ) => LegacyWslRuntimeAuthDestination | null | Promise +} + +const drainQueueByDistro = new Map>() +const completedDistroKeys = new Set() + +export function startLegacyWslRuntimeAuthDrain(options: LegacyWslRuntimeAuthDrainOptions): void { + const key = options.distro.trim().toLowerCase() + if (completedDistroKeys.has(key)) { + return + } + // Coalesce launch/rate-limit callers while a drain is in flight. Queuing a + // new pass for every poll can otherwise build an unbounded promise chain + // while a legacy pane keeps the migration pending. + if (drainQueueByDistro.has(key)) { + return + } + const next = drainLegacyWslRuntimeAuth(options) + .then((status) => { + if (status === 'complete') { + completedDistroKeys.add(key) + } + }) + .catch((error) => { + console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error) + }) + drainQueueByDistro.set(key, next) + void next.finally(() => { + if (drainQueueByDistro.get(key) === next) { + drainQueueByDistro.delete(key) + } + }) +} + +export async function drainLegacyWslRuntimeAuth( + options: LegacyWslRuntimeAuthDrainOptions +): Promise<'complete' | 'pending'> { + const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath) + const inspection = await runWslProcess({ + distro: options.distro, + loginPath: 'none', + script: INSPECT_LEGACY_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (inspection.code === MARKER_PRESENT_EXIT) { + return 'complete' + } + if (inspection.code === SOURCE_AUTH_ABSENT_EXIT) { + if (!options.legacyPanePresent) { + await finalizeAbsentLegacyAuth(options.distro, paths) + return 'complete' + } + return 'pending' + } + assertSuccessfulDrainStep('inspect', inspection) + + const inspected = parseLegacyRuntimeInspection(inspection.stdout) + if (!inspected) { + return 'pending' + } + const destination = await options.resolveDestination(inspected.authContents) + if (!destination) { + return 'pending' + } + const freshness = compareCodexAuthFreshness(inspected.authContents, destination.authContents) + if (freshness === null) { + return 'pending' + } + const promoteAuth = codexAuthIsFresher(inspected.authContents, destination.authContents) + const result = await runWslProcess({ + distro: options.distro, + loginPath: 'none', + script: APPLY_LEGACY_AUTH_SCRIPT, + args: [ + paths.runtimeHome, + paths.activeHome, + paths.marker, + destination.linuxHomePath, + sha256(inspected.authContents), + sha256(destination.authContents), + promoteAuth ? '1' : '0', + options.legacyPanePresent ? '0' : '1', + inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing' + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024 + }) + assertSuccessfulDrainStep('apply', result) + return options.legacyPanePresent ? 'pending' : 'complete' +} + +function parseLegacyRuntimeInspection(stdout: string): LegacyWslRuntimeInspection | null { + const [authBase64, credentialsKind, credentialsBase64] = stdout.split('\n') + const authContents = decodeWslBase64Payload(authBase64 ?? '') + if (authContents === null) { + return null + } + if (credentialsKind === 'missing') { + return { authContents, credentials: { kind: 'missing' } } + } + if (credentialsKind !== 'present') { + return null + } + const credentialsContents = decodeWslBase64Payload(credentialsBase64 ?? '') + if (!credentialsContents || !isJsonObject(credentialsContents)) { + return null + } + return { authContents, credentials: { kind: 'present', contents: credentialsContents } } +} + +function isJsonObject(contents: string): boolean { + try { + const value = JSON.parse(contents) as unknown + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + } catch { + return false + } +} + +function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): { + activeHome: string + marker: string + runtimeHome: string +} { + const runtimeHome = pathPosix.join(guestHomeLinuxPath, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) + const runtimeRoot = pathPosix.dirname(runtimeHome) + return { + activeHome: pathPosix.join(runtimeRoot, 'active', 'wsl', 'home'), + marker: pathPosix.join(runtimeRoot, DRAIN_MARKER_NAME), + runtimeHome + } +} + +async function finalizeAbsentLegacyAuth( + distro: string, + paths: ReturnType +): Promise { + const result = await runWslProcess({ + distro, + loginPath: 'none', + script: FINALIZE_ABSENT_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024 + }) + assertSuccessfulDrainStep('finalize', result) +} + +function assertSuccessfulDrainStep( + step: string, + result: { code: number | null; stderr: string; timedOut: boolean } +): void { + if (result.code === 0 && !result.timedOut) { + return + } + const detail = result.stderr.trim() + throw new Error( + `Legacy WSL auth drain ${step} failed (${result.timedOut ? 'timeout' : `exit ${result.code}`})${detail ? `: ${detail}` : ''}` + ) +} + +function sha256(contents: string): string { + return createHash('sha256').update(contents).digest('hex') +} + +const RESOLVE_LEGACY_HOME_SCRIPT = ` +legacy_home="$1" +legacy_home_resolved=0 +if [ -e "$1" ] || [ -L "$1" ]; then + legacy_home=$(readlink -f -- "$1") || exit 30 + legacy_home_resolved=1 +fi +if [ -e "$2" ] || [ -L "$2" ]; then + active_home=$(readlink -f -- "$2") || exit 31 + if [ "$legacy_home_resolved" = 1 ]; then + [ "$active_home" = "$legacy_home" ] || exit 32 + else + legacy_home="$active_home" + fi +fi +` + +const INSPECT_LEGACY_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit ${MARKER_PRESENT_EXIT} +${RESOLVE_LEGACY_HOME_SCRIPT} +source_auth="$legacy_home/auth.json" +[ -f "$source_auth" ] || exit ${SOURCE_AUTH_ABSENT_EXIT} +encode_file() { + encoded=$(base64 < "$1") || return 1 + printf '%s' "$encoded" | tr -d '\n' +} +encode_file "$source_auth" +printf '\n' +source_credentials="$legacy_home/.credentials.json" +if [ -f "$source_credentials" ]; then + printf 'present\n' + encode_file "$source_credentials" + printf '\n' +elif [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ]; then + printf 'missing\n\n' +else + exit 44 +fi +` + +const APPLY_LEGACY_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit 0 +${RESOLVE_LEGACY_HOME_SCRIPT} +target_home=$(readlink -f -- "$4") || exit 33 +[ "$legacy_home" != "$target_home" ] || exit 34 +source_auth="$legacy_home/auth.json" +target_auth="$target_home/auth.json" +[ -f "$source_auth" ] || exit 35 +[ -f "$target_auth" ] || exit 36 +hash_file() { sha256sum -- "$1" | cut -d ' ' -f 1; } +[ "$(hash_file "$source_auth")" = "$5" ] || exit 37 +[ "$(hash_file "$target_auth")" = "$6" ] || exit 38 +umask 077 +temporary_auth="$target_auth.orca-drain-$$" +temporary_credentials="$target_home/.credentials.json.orca-drain-$$" +temporary_previous_auth="$target_auth.orca-drain-previous-$$" +temporary_marker="$3.orca-drain-$$" +cleanup() { rm -f -- "$temporary_auth" "$temporary_credentials" "$temporary_previous_auth" "$temporary_marker"; } +trap cleanup EXIT HUP INT TERM +source_credentials="$legacy_home/.credentials.json" +target_credentials="$target_home/.credentials.json" +if [ -f "$source_credentials" ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then + [ "$9" != missing ] || exit 43 + [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 + cp -- "$source_credentials" "$temporary_credentials" + chmod 600 "$temporary_credentials" + [ "$(hash_file "$temporary_credentials")" = "$9" ] || exit 43 + [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 + mv -n -- "$temporary_credentials" "$target_credentials" +elif [ "$9" = missing ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then + [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ] || exit 43 +fi +if [ "$7" = 1 ]; then + cp -- "$source_auth" "$temporary_auth" + chmod 600 "$temporary_auth" + # Codex rewrites auth.json in place, so this copy is a second read: verify the + # bytes being promoted, not the ones freshness was judged on. + [ "$(hash_file "$temporary_auth")" = "$5" ] || exit 42 + [ "$(hash_file "$target_auth")" = "$6" ] || exit 39 + # The hard link keeps the destination inode observable without creating a + # missing-path crash window. In-place writers update both names. + ln -- "$target_auth" "$temporary_previous_auth" + [ "$(hash_file "$temporary_previous_auth")" = "$6" ] || exit 39 + mv -f -- "$temporary_auth" "$target_auth" + if [ "$(hash_file "$temporary_previous_auth")" != "$6" ]; then + mv -f -- "$temporary_previous_auth" "$target_auth" + exit 39 + fi + rm -- "$temporary_previous_auth" +fi +if [ "$8" = 1 ]; then + [ "$(hash_file "$source_auth")" = "$5" ] || exit 40 + rm -- "$source_auth" + printf '%s\n' '{"completed":true}' > "$temporary_marker" + chmod 600 "$temporary_marker" + mv -f -- "$temporary_marker" "$3" +fi +` + +const FINALIZE_ABSENT_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit 0 +${RESOLVE_LEGACY_HOME_SCRIPT} +[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41 +umask 077 +temporary_marker="$3.orca-drain-$$" +trap 'rm -f -- "$temporary_marker"' EXIT HUP INT TERM +printf '%s\n' '{"completed":true}' > "$temporary_marker" +chmod 600 "$temporary_marker" +mv -f -- "$temporary_marker" "$3" +` + +export const _internals = { + applyLegacyAuthScript: APPLY_LEGACY_AUTH_SCRIPT, + resetDrainQueue: (): void => { + drainQueueByDistro.clear() + completedDistroKeys.clear() + } +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index cf3a0105d63..e9c0b9a2e0d 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -61,6 +61,7 @@ import { } from '../codex/codex-config-mirror' import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { + getCodexSelectionLaneKey, getWslSelectionKey, getSelectedCodexAccountIdForTarget, normalizeCodexRuntimeSelection, @@ -91,10 +92,16 @@ import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' import { getCodexPaneAccount, + hasRecordedLegacyWslCodexPane, hasRecordedLegacySharedCodexPane, type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { + startLegacyWslRuntimeAuthDrain, + type LegacyWslRuntimeAuthDestination +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' type CodexSystemDefaultSnapshot = { authJson: string | null @@ -244,11 +251,11 @@ export class CodexRuntimeHomeService { ): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) - this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) - const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) - this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) - return runtimeHomePath + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + this.startLegacyWslAuthDrain(wslTarget) + this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, homePath) + this.startWslSessionBridgeForLaunch(wslTarget, homePath) + return homePath } const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { @@ -790,10 +797,9 @@ export class CodexRuntimeHomeService { prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) return { kind: 'ready', - codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) } } const selfContainedAccount = this.getSelfContainedManagedHostAccount() @@ -833,7 +839,7 @@ export class CodexRuntimeHomeService { launchEnv?: NodeJS.ProcessEnv ): void { if (target?.runtime === 'wsl') { - this.syncWslRuntimeForCurrentSelection(target) + this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) return } @@ -1051,28 +1057,128 @@ export class CodexRuntimeHomeService { } private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - const distro = target.wslDistro?.trim() - if (distro) { - const settings = this.store.getSettings() - const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) - if (selectedAccountId === null) { - // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. - return this.getWslSystemCodexHomePath(target) - } - const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) - if ( - cachedRuntimeHomePath && - this.lastSyncedWslAccountIdByDistro.has(distro) && - this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId - ) { - // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. - return cachedRuntimeHomePath - } - } - return this.syncWslRuntimeForCurrentSelection(target) + this.startLegacyWslAuthDrain(target) + return this.getWslCodexHomePathForSelection(target) } - private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { + private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, target) + ) + if (account) { + const managedHome = parseWslUncPath(account.managedHomePath) + const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() + // Persisted selections can outlive an account's runtime metadata. Never + // hand a host home (or another distro's UNC home) to a WSL launch. + const accountDistro = account.wslDistro?.trim() + const distroMatches = + (!accountDistro || + !targetDistro || + accountDistro.toLowerCase() === targetDistro.toLowerCase()) && + (!managedHome || + !targetDistro || + managedHome.distro.toLowerCase() === targetDistro.toLowerCase()) + // Older persisted fixtures may not carry a UNC spelling, but the + // explicit WSL runtime marker still proves the lane; reject unmarked + // host paths while retaining those legacy WSL records. + if (distroMatches && (account.managedHomeRuntime === 'wsl' || managedHome)) { + return account.managedHomePath + } + } + return this.getWslSystemCodexHomePath(target) + } + + private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): void { + if (process.platform !== 'win32') { + return + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const guestHome = getWslHome(distro) + const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null + if (!parsedGuestHome) { + return + } + let legacyPanePresent: boolean + try { + legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) + } catch (error) { + console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) + return + } + startLegacyWslRuntimeAuthDrain({ + distro, + guestHomeLinuxPath: parsedGuestHome.linuxPath, + legacyPanePresent, + resolveDestination: (runtimeAuthContents) => + this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) + }) + } + + private async resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise { + const accounts = this.store.getSettings().codexManagedAccounts.filter((account) => { + const parsedHome = parseWslUncPath(account.managedHomePath) + return parsedHome?.distro.toLowerCase() === distro.toLowerCase() + }) + const accountHomes = accounts.flatMap((account) => { + const parsedHome = parseWslUncPath(account.managedHomePath) + return parsedHome ? [{ account, linuxPath: parsedHome.linuxPath }] : [] + }) + const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null + let reads: WslCodexAuthRead[] + try { + reads = await readWslCodexAuths(distro, [ + ...accountHomes.map(({ linuxPath }) => linuxPath), + ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) + ]) + } catch { + reads = accountHomes.map(() => ({ kind: 'unreadable' })) + if (parsedSystemHome) { + reads.push({ kind: 'unreadable' }) + } + } + const authReads = new Map( + accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) + ) + const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { + accounts, + authReads + }) + if (match.kind === 'ambiguous') { + return null + } + if (match.kind === 'matched') { + const parsedHome = parseWslUncPath(match.account.managedHomePath) + if (!parsedHome || parsedHome.distro.toLowerCase() !== distro.toLowerCase()) { + return null + } + return { + authContents: match.managedAuthContents, + linuxHomePath: parsedHome.linuxPath + } + } + + if (!systemHome || !parsedSystemHome) { + return null + } + const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } + if (systemAuth.kind !== 'present') { + return null + } + return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) + ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } + : null + } + + syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null } @@ -1319,7 +1425,11 @@ export class CodexRuntimeHomeService { private findManagedAccountForRuntimeAuth( runtimeAuthContents: string, - expectedAccountId?: string + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } ): CodexReadBackMatch { const matches: { account: CodexManagedAccount @@ -1327,18 +1437,17 @@ export class CodexRuntimeHomeService { managedAuthContents: string }[] = [] let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of this.store.getSettings().codexManagedAccounts) { + for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { if (expectedAccountId && account.id !== expectedAccountId) { continue } const managedAuthPath = join(account.managedHomePath, 'auth.json') - if (!existsSync(managedAuthPath)) { + let managedAuthContents: string + const suppliedRead = options?.authReads.get(account.id) + if (suppliedRead?.kind === 'missing') { continue } - let managedAuthContents: string - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { + if (suppliedRead?.kind === 'unreadable') { // Why: an unreadable home can never be compared, but letting the read // throw abandons the scan for every other account — dropping a refresh // the runtime home holds for one of them. Only its record can rule it @@ -1351,6 +1460,24 @@ export class CodexRuntimeHomeService { } continue } + if (suppliedRead?.kind === 'present') { + managedAuthContents = suppliedRead.contents + } else { + if (!existsSync(managedAuthPath)) { + continue + } + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + } if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { matches.push({ account, managedAuthPath, managedAuthContents }) } diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index efc7ea3f2b9..95228744bab 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { createSettings } from './runtime-home-settings-test-fixtures' import { @@ -87,18 +87,16 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n') expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath - ) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( - '{"account":"wsl"}\n' + wslManagedHomePath ) + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: getRuntimeCodexHomePath() }) expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ kind: 'ready', - codexHomePath: wslRuntimeHomePath + codexHomePath: wslManagedHomePath }) } finally { if (originalPlatform) { @@ -107,7 +105,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('clears a selected WSL managed account when auth.json is missing', async () => { + it('keeps a selected WSL managed home when auth.json is temporarily missing', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -161,13 +159,11 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + managedHomePath ) - expect(store.updateSettings).toHaveBeenCalledWith({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - }) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(store.updateSettings).not.toHaveBeenCalled() + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -175,7 +171,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => { + it('launches WSL system default against its existing config without a runtime seed', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -214,21 +210,18 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + systemCodexHomePath ) const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') - const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - expect(runtimeConfig).toContain( - `model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'` - ) - expect(runtimeConfig).toContain('[projects."/home/alice/repo"]') - expect(runtimeConfig).not.toContain('[hooks.state.') + expect(existsSync(runtimeConfigPath)).toBe(false) + const systemConfigPath = join(systemCodexHomePath, 'config.toml') + const systemConfig = readFileSync(systemConfigPath, 'utf-8') + expect(systemConfig).toContain('model_instructions_file = "instructions.md"') + expect(systemConfig).toContain('[hooks.state.') - // Why: WSL runtime configs are seeded once; Codex writes trust into them - // afterwards, so a relaunch must not clobber the seeded file. - writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8') + writeFileSync(systemConfigPath, `${systemConfig}\n[projects."/tmp/x"]\n`, 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') + expect(readFileSync(systemConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -255,7 +248,7 @@ describe('CodexRuntimeHomeService', () => { ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") }) - it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { + it('switches WSL accounts by selecting each account home directly', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -317,16 +310,17 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(service.prepareForCodexLaunch(target)).toBe(firstManagedHomePath) + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) store.updateSettings({ activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } } }) service.syncForCurrentSelection(target) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) + expect(service.prepareForCodexLaunch(target)).toBe(secondManagedHomePath) + expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -413,10 +407,10 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + wslManagedHomePath ) expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -476,7 +470,8 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) + mkdirSync(wslRuntimeHomePath, { recursive: true }) writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') @@ -484,7 +479,7 @@ describe('CodexRuntimeHomeService', () => { service.syncForCurrentSelection(target) expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(staleRuntimeAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -492,7 +487,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('reads active WSL token refreshes back before restart using the selected distro', async () => { + it('keeps direct-home WSL token refreshes canonical across restart preservation', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -548,13 +543,13 @@ describe('CodexRuntimeHomeService', () => { ) const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) + writeFileSync(managedAuthPath, refreshedAuth, 'utf-8') service.syncActiveWslSelectionsBeforeRestart() expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(existsSync(runtimeAuthPath)).toBe(false) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index 05cfa12e240..33eae33977f 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type * as WslPaths from '../../shared/wsl-paths' import { createSettings } from './runtime-home-settings-test-fixtures' @@ -89,7 +89,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging after materializing the WSL launch home', async () => { + it('skips WSL session bridging when system default already uses its direct home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -124,17 +124,13 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + wslSystemHomePath ) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ - distro: 'Ubuntu', - systemCodexHomePath: wslSystemHomePath, - managedCodexHomePath: wslRuntimeHomePath - }) - const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md') - expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') - expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled() + expect(readFileSync(join(wslSystemHomePath, 'AGENTS.md'), 'utf-8')).toBe( + '# WSL instructions\n' + ) + expect(existsSync(join(wslRuntimeHomePath, 'AGENTS.md'))).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -144,7 +140,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { + it('keeps WSL in-Codex setting changes in the direct system home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) vi.doMock('../codex/wsl-codex-session-bridge', () => ({ @@ -177,32 +173,20 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - // First launch seeds the runtime config and records the per-distro baseline. expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + join(wslHome, '.codex') ) const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json') - expect(existsSync(baselinePath)).toBe(true) + expect(existsSync(baselinePath)).toBe(false) - // A direct WSL Codex edit wins and is mirrored into Orca's runtime before - // the baseline advances, so later in-Orca changes remain promotable. - const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') - expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') - - // Codex now persists a /model change inside Orca's reconciled runtime. - writeFileSync( - runtimeConfigPath, - readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'), - 'utf-8' - ) + expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + writeFileSync(wslSystemConfigPath, 'model = "o4"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n') - // Baseline advances so the promoted value is not re-promoted forever. - expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') + expect(existsSync(baselinePath)).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -236,22 +220,13 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Ubuntu', systemCodexHomePath: '/home/me/.config/codex', - managedCodexHomePath: wslRuntimeHomePath + managedCodexHomePath: join(wslHome, '.codex') }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') @@ -287,7 +262,8 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate === wslRuntimeHomePath + candidate === wslRuntimeHomePath || + candidate.includes('codex-accounts/debian-account/home') ? { distro: 'Debian', linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' @@ -328,12 +304,12 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe( - wslRuntimeHomePath + managedHomePath ) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Debian', systemCodexHomePath: join(wslHome, '.codex'), - managedCodexHomePath: wslRuntimeHomePath + managedCodexHomePath: managedHomePath }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index a1ad86a0a5f..dbf0241a155 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -93,15 +93,6 @@ describe('CodexRuntimeHomeService', () => { const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token') const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth) const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth) - const runtimeAuthPath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home', - 'auth.json' - ) const store = createStore( createSettings({ codexManagedAccounts: [ @@ -147,9 +138,9 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({ kind: 'ready', - codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + codexHomePath: ubuntuHomePath }) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) + expect(readFileSync(join(ubuntuHomePath, 'auth.json'), 'utf-8')).toBe(ubuntuAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -228,7 +219,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('reads WSL system-default token refreshes back to WSL system auth', async () => { + it('keeps WSL system-default token refreshes in its direct home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -257,21 +248,11 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + writeFileSync(join(systemCodexHomePath, 'auth.json'), refreshedAuth, 'utf-8') - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') - - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -279,7 +260,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('preserves WSL system-default token refreshes after app restart', async () => { + it('does not overwrite direct WSL system auth from the retired runtime on restart', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -319,8 +300,8 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { diff --git a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts new file mode 100644 index 00000000000..40870fcd5ef --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts @@ -0,0 +1,70 @@ +import { runWslProcess } from '../wsl/wsl-runner' + +export type WslCodexAuthRead = + | { kind: 'missing' | 'unreadable' } + | { kind: 'present'; contents: string } + +export async function readWslCodexAuths( + distro: string, + linuxHomePaths: readonly string[] +): Promise { + if (linuxHomePaths.length === 0) { + return [] + } + const result = await runWslProcess({ + distro, + loginPath: 'none', + script: READ_AUTHS_SCRIPT, + args: linuxHomePaths, + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (result.code !== 0 || result.timedOut) { + return linuxHomePaths.map(() => ({ kind: 'unreadable' })) + } + const rows = result.stdout.split('\n') + return linuxHomePaths.map((_, index) => parseAuthReadRow(rows[index] ?? '')) +} + +export function decodeWslBase64Payload(encoded: string): string | null { + try { + const decoded = Buffer.from(encoded, 'base64') + const canonical = decoded.toString('base64').replace(/=+$/, '') + return canonical === encoded.replace(/=+$/, '') ? decoded.toString('utf8') : null + } catch { + return null + } +} + +function parseAuthReadRow(row: string): WslCodexAuthRead { + if (row === 'missing' || row === 'unreadable') { + return { kind: row } + } + if (!row.startsWith('present:')) { + return { kind: 'unreadable' } + } + const contents = decodeWslBase64Payload(row.slice('present:'.length)) + return contents === null ? { kind: 'unreadable' } : { kind: 'present', contents } +} + +const READ_AUTHS_SCRIPT = ` +set -eu +for home_path in "$@"; do + auth_path="$home_path/auth.json" + if [ ! -f "$auth_path" ]; then + if [ ! -e "$auth_path" ] && [ ! -L "$auth_path" ]; then + printf 'missing\n' + else + printf 'unreadable\n' + fi + continue + fi + if encoded=$(base64 < "$auth_path"); then + printf 'present:' + printf '%s' "$encoded" | tr -d '\n' + printf '\n' + else + printf 'unreadable\n' + fi +done +` diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 449c11ba711..4a4467bd219 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -266,6 +266,15 @@ export function hasRecordedLegacySharedCodexPane(): boolean { ) } +/** True when a retained WSL pane may still read the retired per-distro runtime home. */ +export function hasRecordedLegacyWslCodexPane(selectionKey: string): boolean { + return Object.values(readRegistryOrThrow().panes).some( + (record) => + (record.selectionKey === selectionKey || record.selectionKey === 'wsl:__default__') && + (record.homeRoute === undefined || record.homeRoute === 'wsl-home') + ) +} + /** True when startup may need to repair hooks for a retained managed host pane. */ export function hasRecordedManagedHostCodexPane(): boolean { return Object.values(readRegistry().panes).some( diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index 456b9bc08be..c862099e3ac 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -202,7 +202,7 @@ describe('resolveCodexPaneLaunchAccount', () => { ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account', - homeRoute: 'wsl-home' + homeRoute: 'account-home' }) }) diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index b99700da081..089c1975db3 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' import { getCodexSelectionLaneKey, getCodexSelectionTargetForAccount, @@ -80,9 +81,6 @@ function resolveCodexPaneHomeRoute(args: { settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneHomeRoute { - if (args.target.runtime === 'wsl') { - return 'wsl-home' - } if ( !args.launchCodexHomePath || normalizeRuntimePathForComparison(args.launchCodexHomePath) === @@ -93,10 +91,19 @@ function resolveCodexPaneHomeRoute(args: { const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) const accountOwnsHome = args.settings.codexManagedAccounts?.some( (account) => - getCodexSelectionTargetForAccount(account).runtime === 'host' && + getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) === + getCodexSelectionLaneKey(args.target) && normalizeRuntimePathForComparison(account.managedHomePath) === launchHome ) - return accountOwnsHome ? 'account-home' : 'shared-home' + if (accountOwnsHome) { + return 'account-home' + } + if (args.target.runtime === 'wsl') { + return parseWslUncPath(args.launchCodexHomePath)?.linuxPath.endsWith('/.codex') + ? 'real-home' + : 'wsl-home' + } + return 'shared-home' } /** undefined when no account owns the home; null means the system-default account. */ diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index bf2f7e5b5ea..c5deb104543 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -8,6 +8,7 @@ import { forgetCodexPaneAccount, getCodexPaneAccount, hasRecordedLegacySharedCodexPane, + hasRecordedLegacyWslCodexPane, hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, @@ -138,6 +139,36 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) + it('identifies only legacy runtime-home panes on the requested WSL lane', () => { + recordCodexPaneAccount('pty-legacy', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-old', + homeRoute: 'wsl-home' + }) + recordCodexPaneAccount('pty-direct', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-new', + homeRoute: 'account-home' + }) + recordCodexPaneAccount('pty-other-distro', { + selectionKey: 'wsl:Debian', + accountId: 'account-debian', + homeRoute: 'wsl-home' + }) + recordCodexPaneAccount('pty-default', { + selectionKey: 'wsl:__default__', + accountId: null, + homeRoute: 'wsl-home' + }) + + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) + forgetCodexPaneAccount('pty-legacy') + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) + forgetCodexPaneAccount('pty-default') + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false) + expect(hasRecordedLegacyWslCodexPane('wsl:Debian')).toBe(true) + }) + it('requests startup inventory only for managed host panes', () => { recordCodexPaneAccount('pty-real', { selectionKey: 'host', From 9fb522023979c0d56761782be024c7630f6046f1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:17:59 -0700 Subject: [PATCH 005/118] Prevent duplicate file renames when input unmounts after Enter (#16719) When Enter is pressed to confirm a rename, the input unmounts and its onBlur handler fires as it detaches from the DOM. Without consuming this event, a second commitRename call would attempt to rename against the old path. Setting the cancel flag after capturing the new name causes the trailing onBlur to return early, preventing the duplicate operation. --- .../editor/editor-header-file-rename.ts | 4 ++- .../components/tab-bar/EditorFileTab.test.tsx | 24 ++++++++++++++ .../src/components/tab-bar/EditorFileTab.tsx | 4 ++- tests/e2e/floating-tab-rename.spec.ts | 31 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/editor/editor-header-file-rename.ts b/src/renderer/src/components/editor/editor-header-file-rename.ts index 16f5e557faa..e8a787dd2b9 100644 --- a/src/renderer/src/components/editor/editor-header-file-rename.ts +++ b/src/renderer/src/components/editor/editor-header-file-rename.ts @@ -42,7 +42,6 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil const commitRename = (): void => { if (renameCancelledRef.current) { - renameCancelledRef.current = false setIsRenaming(false) return } @@ -52,6 +51,9 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil return } const newName = input.value.trim() + // onBlur follows Enter when the input unmounts; consume that trailing event + // so one user action cannot start a second rename against the old path. + renameCancelledRef.current = true setIsRenaming(false) if (!newName || newName === currentFileName) { return diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 4edf74a7cba..6eb614550c9 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -454,6 +454,30 @@ describe('EditorFileTab rename menu', () => { }) }) + it('does not re-commit when unmounting the rename input emits multiple blur events', async () => { + const file = baseFile() + const firstRender = expandNode((await renderEditorFileTab(file)).element) + const renameItem = findMenuItemByText(firstRender, 'Rename') + + ;(renameItem.props.onSelect as () => void)() + + const secondRender = expandNode((await renderEditorFileTab(file)).element) + const input = findElementsByType(secondRender, 'input')[0] + const setInputRef = input.props.ref as (input: HTMLInputElement | null) => void + setInputRef({ + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + value: 'renamed.md' + } as unknown as HTMLInputElement) + + pressInputKey(input, 'Enter') + ;(input.props.onBlur as () => void)() + ;(input.props.onBlur as () => void)() + + expect(renameFileOnDiskMock).toHaveBeenCalledTimes(1) + }) + it('disables Rename for diff tabs that do not map to one writable file', async () => { const file = baseFile({ mode: 'diff', diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index aca2014bb91..028c0b745b6 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -132,7 +132,6 @@ export default function EditorFileTab({ const commitRename = (): void => { if (renameCancelledRef.current) { - renameCancelledRef.current = false setIsRenaming(false) return } @@ -142,6 +141,9 @@ export default function EditorFileTab({ return } const newName = input.value.trim() + // onBlur follows Enter when the input unmounts; consume that trailing event + // so one user action cannot start a second rename against the old path. + renameCancelledRef.current = true setIsRenaming(false) if (!newName) { return diff --git a/tests/e2e/floating-tab-rename.spec.ts b/tests/e2e/floating-tab-rename.spec.ts index 94efe4650ee..6f671789353 100644 --- a/tests/e2e/floating-tab-rename.spec.ts +++ b/tests/e2e/floating-tab-rename.spec.ts @@ -141,6 +141,37 @@ test('concurrent floating Markdown renames do not clobber the destination', asyn ).toEqual(['first\n', 'second\n']) }) +test('Enter commits a floating Markdown rename only once', async ({ orcaPage }) => { + const seeded = await seedFloatingMarkdownFile(orcaPage) + await openFloatingPanel(orcaPage) + + const panel = orcaPage.locator(OPEN_PANEL_SELECTOR) + const tab = panel.locator(`[data-tab-id="${seeded.tabId}"]`) + await tab.click({ button: 'right' }) + await orcaPage.getByRole('menuitem').filter({ hasText: 'Rename' }).first().click() + + const input = panel.getByRole('textbox', { + name: `Rename file ${seeded.originalName}`, + exact: true + }) + await input.fill(seeded.renamedName) + await input.press('Enter') + + await expect(tab).toContainText(seeded.renamedName) + await expect + .poll(() => + orcaPage.evaluate( + async ({ originalPath, renamedPath }) => ({ + originalExists: await window.api.fs.pathExists({ filePath: originalPath }), + renamedExists: await window.api.fs.pathExists({ filePath: renamedPath }) + }), + { originalPath: seeded.originalPath, renamedPath: seeded.renamedPath } + ) + ) + .toEqual({ originalExists: false, renamedExists: true }) + await expect(orcaPage.getByText(/Failed to rename/)).toHaveCount(0) +}) + test('Electron serializes native Unicode rename aliases', async ({ orcaPage }) => { test.skip(process.platform !== 'darwin', 'Requires native Unicode aliasing') const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) From 673842db35b37557006d9f20d04e077780253d82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:24:32 -0700 Subject: [PATCH 006/118] refactor(codex): remove WSL runtime mirror machinery (#16505) * refactor(codex): remove WSL runtime mirror machinery * test(codex): drop allowlist entries the mirror removal made stale runtime-home-service.ts no longer spawns wsl.exe or imports child_process; both boundary guards fail closed on a stale entry so the goalpost keeps moving. * fix(codex): drain legacy WSL auth before restart * fix(codex): await WSL auth drain before restart --- .../agent-auth-restart-preservation.test.ts | 126 ++++-- src/main/agent-auth-restart-preservation.ts | 49 +-- .../legacy-wsl-runtime-auth-drain.ts | 12 +- .../codex-accounts/runtime-home-service.ts | 390 ++---------------- .../runtime-home-wsl-managed-accounts.test.ts | 182 +------- .../runtime-home-wsl-session-bridge.test.ts | 68 +-- .../runtime-home-wsl-system-default.test.ts | 10 - src/main/pty/codex-home-wsl-env.ts | 4 +- .../__fixtures__/wsl-invocation-allowlist.txt | 1 - .../child-process-import-allowlist.txt | 1 - 10 files changed, 164 insertions(+), 679 deletions(-) diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 441ec609624..b94bcc2435d 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -15,8 +15,7 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn() + }) }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -33,35 +32,13 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['codex', 'claude', 'flush']) }) - it('runs WSL Codex preservation through the runtime service', async () => { - const syncForCurrentSelection = vi.fn() - const syncActiveWslSelectionsBeforeRestart = vi.fn() - - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection, - syncActiveWslSelectionsBeforeRestart - }, - store: { - flushPendingOrThrowAsync: vi.fn() - } - }) - - expect(syncForCurrentSelection).toHaveBeenCalledTimes(1) - expect(syncForCurrentSelection).toHaveBeenNthCalledWith(1) - expect(syncActiveWslSelectionsBeforeRestart).toHaveBeenCalledTimes(1) - }) - - it('runs Claude preservation before WSL Codex preservation', async () => { + it('runs Claude preservation after Codex and before the store flush', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex-host') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn(() => { - calls.push('codex-wsl') }) }, claudeRuntimeAuth: { @@ -76,26 +53,80 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex-host', 'claude', 'codex-wsl', 'flush']) + expect(calls).toEqual(['codex-host', 'claude', 'flush']) }) - it('continues after WSL Codex preservation fails', async () => { + it('drains retained WSL Codex auth before flushing the store', async () => { + const calls: string[] = [] + + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection: vi.fn(() => { + calls.push('codex-host') + }), + syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { + calls.push('codex-wsl') + }) + }, + store: { + flushPendingOrThrowAsync: vi.fn(async () => { + calls.push('flush') + }) + } + }) + + expect(calls).toEqual(['codex-host', 'codex-wsl', 'flush']) + }) + + it('does not release restart while the bounded WSL drain is still running', async () => { + vi.useFakeTimers() + let finishWslDrain!: () => void + let settled = false + const flushPendingOrThrowAsync = vi.fn() + + const preservation = preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection: vi.fn(), + syncActiveWslSelectionsBeforeRestart: vi.fn( + () => + new Promise((resolve) => { + finishWslDrain = resolve + }) + ) + }, + store: { flushPendingOrThrowAsync } + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(1) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + + finishWslDrain() + await preservation + expect(settled).toBe(true) + }) + + it('continues after the bounded WSL drain fails without logging secrets', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const flushPendingOrThrowAsync = vi.fn() await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(), - syncActiveWslSelectionsBeforeRestart: vi.fn(() => { + syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { throw new Error('wsl-token-secret') }) }, - store: { - flushPendingOrThrowAsync - } + store: { flushPendingOrThrowAsync } }) expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[agent-auth-restart] Codex auth preservation failed (Error); continuing restart/update' + ) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) @@ -116,8 +147,7 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { throw new Error('codex-token-secret') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn() + }) }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -170,6 +200,36 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) }) + it('gives store persistence its own timeout after auth preservation expires', async () => { + vi.useFakeTimers() + let finishStoreFlush!: () => void + let settled = false + + const preservation = preserveAgentAuthBeforeRestart({ + claudeRuntimeAuth: { + syncForCurrentSelection: vi.fn(() => new Promise(() => {})) + }, + store: { + flushPendingOrThrowAsync: vi.fn( + () => + new Promise((resolve) => { + finishStoreFlush = resolve + }) + ) + } + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(false) + + finishStoreFlush() + await preservation + expect(settled).toBe(true) + }) + it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index eaae45f331b..e351bfee334 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -4,10 +4,8 @@ import type { Store } from './persistence' const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 -type CodexRuntimeAuthSync = Pick< - CodexRuntimeHomeService, - 'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart' -> +type CodexRuntimeAuthSync = Pick & + Partial> type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick @@ -27,35 +25,26 @@ export async function preserveAgentAuthBeforeRestart({ claudeRuntimeAuth, store }: AgentAuthRestartPreservationOptions): Promise { - const startedAt = Date.now() - runCodexPreservationStep(codexRuntimeHome) + // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. + const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) - const remainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) - if (claudeRuntimeAuth && remainingMs > 0) { + if (claudeRuntimeAuth) { await runWithinLifecycleTimeout( 'Claude auth preservation', () => claudeRuntimeAuth.syncForCurrentSelection(), - remainingMs - ) - } else if (claudeRuntimeAuth) { - logStepTimeout('Claude auth preservation', 0) - } - - if (codexRuntimeHome && Date.now() - startedAt < AUTH_PRESERVATION_TIMEOUT_MS) { - runWslCodexPreservationStep(codexRuntimeHome) - } else if (codexRuntimeHome) { - logStepTimeout('Codex auth preservation', 0) - } - - if (store) { - const storeRemainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) - await runWithinLifecycleTimeout( - 'Store persistence', - () => store.flushPendingOrThrowAsync(), - storeRemainingMs + AUTH_PRESERVATION_TIMEOUT_MS ) } + + const storePreservation = store + ? runWithinLifecycleTimeout( + 'Store persistence', + () => store.flushPendingOrThrowAsync(), + AUTH_PRESERVATION_TIMEOUT_MS + ) + : Promise.resolve() + await Promise.all([wslCodexPreservation, storePreservation]) } function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null | undefined): void { @@ -66,11 +55,11 @@ function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null } } -function runWslCodexPreservationStep( +async function runWslCodexPreservationStep( codexRuntimeHome: CodexRuntimeAuthSync | null | undefined -): void { +): Promise { try { - codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart() + await codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart?.() } catch (error) { logStepFailure('Codex auth preservation', error) } @@ -89,7 +78,7 @@ async function runWithinLifecycleTimeout( }) // Why: this timeout only releases the restart/update path. It does not - // cancel a sync that already started, and Codex sync is synchronous today. + // cancel a sync that already started. const timeoutResult = new Promise<'timeout'>((resolve) => { timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts index cc50be185af..bde5d4c9ebc 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -31,16 +31,19 @@ type LegacyWslRuntimeAuthDrainOptions = { const drainQueueByDistro = new Map>() const completedDistroKeys = new Set() -export function startLegacyWslRuntimeAuthDrain(options: LegacyWslRuntimeAuthDrainOptions): void { +export function startLegacyWslRuntimeAuthDrain( + options: LegacyWslRuntimeAuthDrainOptions +): Promise { const key = options.distro.trim().toLowerCase() if (completedDistroKeys.has(key)) { - return + return Promise.resolve() } // Coalesce launch/rate-limit callers while a drain is in flight. Queuing a // new pass for every poll can otherwise build an unbounded promise chain // while a legacy pane keeps the migration pending. - if (drainQueueByDistro.has(key)) { - return + const inFlight = drainQueueByDistro.get(key) + if (inFlight) { + return inFlight } const next = drainLegacyWslRuntimeAuth(options) .then((status) => { @@ -57,6 +60,7 @@ export function startLegacyWslRuntimeAuthDrain(options: LegacyWslRuntimeAuthDrai drainQueueByDistro.delete(key) } }) + return next } export async function drainLegacyWslRuntimeAuth( diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index e9c0b9a2e0d..7975c7172ad 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,5 +1,4 @@ /* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ -import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { appendFileSync, copyFileSync, @@ -18,7 +17,6 @@ import { unlinkSync } from 'node:fs' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { execFileSync } from 'node:child_process' import { dirname, extname, @@ -33,7 +31,6 @@ import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/managed-account-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' -import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { recoverInterruptedGuardedFileOperation, removeFileAtomicallyIfUnchanged, @@ -55,17 +52,13 @@ import { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' -import { - prepareSystemConfigForFreshRuntimeMirror, - syncSystemConfigIntoManagedCodexHome -} from '../codex/codex-config-mirror' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { getCodexSelectionLaneKey, getWslSelectionKey, getSelectedCodexAccountIdForTarget, normalizeCodexRuntimeSelection, - setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' @@ -137,7 +130,6 @@ type CodexRuntimeLogoutMarkerStatus = | { kind: 'applies' } | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } -type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' type CodexReadBackMatch = | { kind: 'matched' @@ -202,11 +194,6 @@ export class CodexRuntimeHomeService { private lastSyncedAccountId: string | null = null // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). private lastWrittenAuthJson: string | null = null - // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. - private readonly lastWrittenWslAuthJsonByDistro = new Map() - private readonly lastSyncedWslAccountIdByDistro = new Map() - private readonly wslRuntimeHomePathByDistro = new Map() - private skipNextReadBackForAccountId: string | null = null // Why: a managed host account refreshes auth in its own home. Remember that // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false @@ -731,26 +718,6 @@ export class CodexRuntimeHomeService { syncLegacySharedCodexConfigForRetainedPanes() } - syncActiveWslSelectionsBeforeRestart(): void { - if (process.platform !== 'win32') { - return - } - - const settings = this.store.getSettings() - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey) - } - } - private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null @@ -886,7 +853,6 @@ export class CodexRuntimeHomeService { // distro-local runtime home, so the host mirror only drops its baseline. this.lastSyncedAccountId = null this.lastWrittenAuthJson = null - this.skipNextReadBackForAccountId = null return } if (normalizeCodexRuntimeSelection(settings).host) { @@ -928,71 +894,13 @@ export class CodexRuntimeHomeService { } } - // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. + // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. clearLastWrittenAuthJson( accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host ): void { if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null } - this.skipNextReadBackForAccountId = accountId - } - - private readBackRefreshedTokensFromPath( - runtimeAuthPath: string, - options: { - updateLastWrittenAuthJson: boolean - lastWrittenAuthJson?: string | null - setLastWrittenAuthJson?: (contents: string) => void - expectedAccountId?: string - } - ): CodexReadBackResult { - try { - if (!existsSync(runtimeAuthPath)) { - return 'unchanged' - } - - const lastWrittenAuthJson = - options.lastWrittenAuthJson === undefined - ? this.lastWrittenAuthJson - : options.lastWrittenAuthJson - const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { - return 'unchanged' - } - - const match = this.findManagedAccountForRuntimeAuth( - runtimeContents, - options.expectedAccountId - ) - if (match.kind !== 'matched') { - if (match.kind === 'ambiguous') { - console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') - } - return 'rejected' - } - // Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage. - if ( - lastWrittenAuthJson === null && - !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) - ) { - return 'rejected' - } - - writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) - if (options.updateLastWrittenAuthJson) { - if (options.setLastWrittenAuthJson) { - options.setLastWrittenAuthJson(runtimeContents) - } else { - this.lastWrittenAuthJson = runtimeContents - } - } - return 'persisted' - } catch (error) { - // Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle. - console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) - return 'rejected' - } } // Why: which ~/.codex bytes the mirror was seeded from, and whether the system @@ -1057,7 +965,6 @@ export class CodexRuntimeHomeService { } private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - this.startLegacyWslAuthDrain(target) return this.getWslCodexHomePathForSelection(target) } @@ -1090,27 +997,27 @@ export class CodexRuntimeHomeService { return this.getWslSystemCodexHomePath(target) } - private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): void { + private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): Promise { if (process.platform !== 'win32') { - return + return Promise.resolve() } const distro = target.wslDistro?.trim() || getDefaultWslDistro() if (!distro) { - return + return Promise.resolve() } const guestHome = getWslHome(distro) const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null if (!parsedGuestHome) { - return + return Promise.resolve() } let legacyPanePresent: boolean try { legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) } catch (error) { console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) - return + return Promise.resolve() } - startLegacyWslRuntimeAuthDrain({ + return startLegacyWslRuntimeAuthDrain({ distro, guestHomeLinuxPath: parsedGuestHome.linuxPath, legacyPanePresent, @@ -1119,6 +1026,34 @@ export class CodexRuntimeHomeService { }) } + /** Preserve refreshed auth from retained legacy WSL panes before restart. */ + async syncActiveWslSelectionsBeforeRestart(): Promise { + if (process.platform !== 'win32') { + return + } + const settings = this.store.getSettings() + const drains: Promise[] = [] + for (const [selectedDistroKey, accountId] of Object.entries( + normalizeCodexRuntimeSelection(settings).wsl + )) { + if (!accountId) { + continue + } + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + if (!account || account.managedHomeRuntime !== 'wsl') { + continue + } + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() || null + : selectedDistroKey.trim() || null + if (distro) { + drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) + } + } + await Promise.all(drains) + } + private async resolveLegacyWslAuthDestination( distro: string, runtimeAuthContents: string @@ -1178,204 +1113,6 @@ export class CodexRuntimeHomeService { : null } - syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { - if (process.platform !== 'win32') { - return null - } - - const wslTarget = this.resolveWslDefaultTarget(target) - const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, wslTarget) - ) - const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() - if (!distro) { - return null - } - - const runtimeHomePath = this.getWslRuntimeHomePath(distro) - if (!runtimeHomePath) { - return null - } - this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath) - - mkdirSync(runtimeHomePath, { recursive: true }) - this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) - this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) - - const runtimeAuthPath = join(runtimeHomePath, 'auth.json') - const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null - if (previousWslAccountId) { - if (this.skipNextReadBackForAccountId === previousWslAccountId) { - this.skipNextReadBackForAccountId = null - } else { - const previousWslAccount = this.getActiveAccount( - settings.codexManagedAccounts, - previousWslAccountId - ) - if (previousWslAccount) { - this.readBackRefreshedTokensFromPath(runtimeAuthPath, { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: previousWslAccount.id - }) - } - } - } - - const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null - if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { - const activeAuth = readFileSync(activeAuthPath, 'utf-8') - this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) - return runtimeHomePath - } - if (activeAccount && activeAuthPath) { - console.warn( - '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' - ) - this.store.updateSettings({ - activeCodexManagedAccountId: settings.activeCodexManagedAccountId, - activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( - normalizeCodexRuntimeSelection(settings), - null, - wslTarget - ) - }) - } - - const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) - if (systemAuthPath && existsSync(systemAuthPath)) { - const systemAuth = readFileSync(systemAuthPath, 'utf-8') - const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null - const runtimeAuth = existsSync(runtimeAuthPath) - ? readFileSync(runtimeAuthPath, 'utf-8') - : null - if ( - runtimeAuth !== null && - runtimeAuth !== systemAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && - ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || - (mirroredSystemDefaultAuth === null && - this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) - ) { - // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. - this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenWslAuthJsonByDistro.set(distro, null) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - - private getWslRuntimeHomePath(distro: string): string | null { - const home = getWslHome(distro) - return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null - } - - private safeReadBackActiveWslAccountBeforeRestart( - account: CodexManagedAccount, - selectedDistroKey: string - ): void { - try { - this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey) - } catch (error) { - console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error) - } - } - - private readBackActiveWslAccountBeforeRestart( - account: CodexManagedAccount, - selectedDistroKey: string - ): void { - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() - : selectedDistroKey.trim() || account.wslDistro?.trim() - if (!distro) { - return - } - - const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) - if (!runtimeHomePath) { - return - } - - this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: account.id - }) - } - - private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { - try { - this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath) - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error) - } - } - - private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { - const runtimeWsl = parseWslUncPath(runtimeHomePath) - if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) { - return - } - const activeLinuxPath = runtimeWsl.linuxPath.replace( - /\/codex-runtime-home\/home$/, - '/codex-runtime-home/active/wsl/home' - ) - const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` - const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) - // Why: login-shell cleanup turns `exit 0` into status 1, so fall through. - execFileSync( - 'wsl.exe', - [ - '-d', - distro, - '--exec', - 'bash', - '-lc', - [ - 'set -e', - `if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, - `elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, - 'else', - `mkdir -p ${quotePosixShell(activeLinuxParentPath)}`, - `rm -rf -- ${quotePosixShell(nextLinuxPath)}`, - `ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`, - `mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`, - 'fi' - ].join('\n') - ], - // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes - // a conhost and steals foreground for up to the timeout (#10488). - { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } - ) - } - - private dirnameLinuxPath(value: string): string { - const index = value.lastIndexOf('/') - return index > 0 ? value.slice(0, index) : '/' - } - private joinWslPath(basePath: string, ...segments: string[]): string { return parseWslUncPath(basePath) ? pathWin32.join(basePath, ...segments) @@ -1392,37 +1129,6 @@ export class CodexRuntimeHomeService { return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target } - private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { - const home = this.getWslSystemCodexHomePath(target) - return home ? this.joinWslPath(home, 'auth.json') : null - } - - private seedWslRuntimeHome( - runtimeHomePath: string, - activeAccount: CodexManagedAccount | null, - distro: string - ): void { - const runtimeConfigPath = join(runtimeHomePath, 'config.toml') - if (existsSync(runtimeConfigPath)) { - return - } - - const candidateHomes = [ - activeAccount?.managedHomePath, - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - ].filter((value): value is string => Boolean(value)) - for (const homePath of candidateHomes) { - const configPath = join(homePath, 'config.toml') - if (existsSync(configPath)) { - writeFileAtomically( - runtimeConfigPath, - prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath) - ) - return - } - } - } - private findManagedAccountForRuntimeAuth( runtimeAuthContents: string, expectedAccountId?: string, @@ -1499,10 +1205,6 @@ export class CodexRuntimeHomeService { return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) } - private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { - return codexAuthIsFresher(runtimeAuthContents, managedAuthContents) - } - private safeMigrateLegacySharedAuth(): void { const settings = this.store.getSettings() try { @@ -2209,15 +1911,6 @@ export class CodexRuntimeHomeService { return true } - private writeRuntimeAuthAtPath(authPath: string, contents: string): void { - if (this.fileContentsEqual(authPath, contents)) { - this.ensureOwnerOnlyMode(authPath) - return - } - mkdirSync(dirname(authPath), { recursive: true }) - writeFileAtomically(authPath, contents, { mode: 0o600 }) - } - /** * `true`/`false` only when the bytes were actually read; `null` when the file * could not be read at all. The old `catch { return false }` reported "these @@ -2524,14 +2217,3 @@ export class CodexRuntimeHomeService { rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) } } - -// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2). -export function prepareWslRuntimeSeedConfig( - configContents: string, - sourceHomePath: string -): string { - return prepareSystemConfigForFreshRuntimeMirror( - configContents, - parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath - ) -} diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index 95228744bab..81593c46013 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -229,25 +229,6 @@ describe('CodexRuntimeHomeService', () => { } }) - it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => { - const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service') - - // Why: real UNC sources cannot back live fs operations in tests, so pin - // the UNC -> Linux-side anchor translation on the extracted seed function. - expect( - prepareWslRuntimeSeedConfig( - 'model_instructions_file = "instructions.md"\n', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' - ) - ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") - expect( - prepareWslRuntimeSeedConfig( - 'model_instructions_file = "instructions.md"\n', - '\\\\wsl$\\Ubuntu\\home\\alice\\.codex' - ) - ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") - }) - it('switches WSL accounts by selecting each account home directly', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -328,7 +309,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('does not use host auth baseline to accept stale WSL runtime auth', async () => { + it('ignores stale retired runtime auth when launching a managed WSL account', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -336,7 +317,6 @@ describe('CodexRuntimeHomeService', () => { getDefaultWslDistro: () => 'Ubuntu', getWslHome: () => wslHome })) - const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token') const wslManagedAuth = createCodexAuthJson( 'wsl@example.com', 'acct-wsl', @@ -349,7 +329,6 @@ describe('CodexRuntimeHomeService', () => { 'runtime-stale', 1_000 ) - const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) const wslManagedHomePath = createManagedAuth( testState.userDataDir, 'wsl-account', @@ -368,17 +347,6 @@ describe('CodexRuntimeHomeService', () => { const store = createStore( createSettings({ codexManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedHomePath: hostManagedHomePath, - providerAccountId: 'acct-host', - workspaceLabel: null, - workspaceAccountId: 'acct-host', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, { id: 'wsl-account', email: 'wsl@example.com', @@ -389,14 +357,13 @@ describe('CodexRuntimeHomeService', () => { providerAccountId: 'acct-wsl', workspaceLabel: null, workspaceAccountId: 'acct-wsl', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 } ], - activeCodexManagedAccountId: 'host-account', activeCodexManagedAccountIdsByRuntime: { - host: 'host-account', + host: null, wsl: { Ubuntu: 'wsl-account' } } }) @@ -417,143 +384,4 @@ describe('CodexRuntimeHomeService', () => { } } }) - - it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000) - const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500) - const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'wsl@example.com', - managedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-wsl', - workspaceLabel: null, - workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'account-1' } - } - }) - ) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) - mkdirSync(wslRuntimeHomePath, { recursive: true }) - writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') - writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') - - service.clearLastWrittenAuthJson('account-1') - service.syncForCurrentSelection(target) - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(staleRuntimeAuth) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('keeps direct-home WSL token refreshes canonical across restart preservation', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed', 1_000) - const refreshedAuth = createCodexAuthJson( - 'wsl@example.com', - 'acct-wsl', - 'runtime-refreshed', - 2_000 - ) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'wsl@example.com', - managedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: null, - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-wsl', - workspaceLabel: null, - workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'account-1' } - } - }) - ) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - - expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) - writeFileSync(managedAuthPath, refreshedAuth, 'utf-8') - - service.syncActiveWslSelectionsBeforeRestart() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(existsSync(runtimeAuthPath)).toBe(false) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) }) diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index 33eae33977f..fe91d223a40 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -34,61 +34,6 @@ describe('CodexRuntimeHomeService', () => { teardownRuntimeHomeTest() }) - it('builds a valid WSL legacy active-home migration shell command', async () => { - const execFileSyncMock = vi.fn() - vi.doMock('node:child_process', () => ({ execFileSync: execFileSyncMock })) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService( - createStore(createSettings()) as never - ) as unknown as { - migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void - } - - service.migrateLegacyWslActiveHomePointer( - 'Ubuntu', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' - ) - - expect(execFileSyncMock).toHaveBeenCalledTimes(1) - const firstCall = execFileSyncMock.mock.calls[0] - expect(firstCall).toBeDefined() - const [command, args] = firstCall as [string, string[]] - expect(command).toBe('wsl.exe') - expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc']) - expect(args).toHaveLength(6) - - const shellCommand = args[5] - expect(shellCommand).toContain( - "if [ ! -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" - ) - expect(shellCommand).toContain( - "elif [ -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" - ) - expect(shellCommand).toContain( - "mkdir -p '/home/alice/.local/share/orca/codex-runtime-home/active/wsl'" - ) - expect(shellCommand).toContain( - "ln -s -- '/home/alice/.local/share/orca/codex-runtime-home/home' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" - ) - expect(shellCommand).toContain( - "mv -Tf -- '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" - ) - expect(shellCommand).toContain( - "' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home'" - ) - expect(shellCommand).not.toContain('[! -L') - expect(shellCommand).not.toContain('mv -Tf--') - expect(shellCommand).not.toContain('$1') - expect(shellCommand).not.toContain('$2') - expect(shellCommand).not.toContain('$3') - expect(shellCommand).not.toContain('exit 0') - } finally { - vi.doUnmock('node:child_process') - } - }) - it('skips WSL session bridging when system default already uses its direct home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -237,7 +182,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { + it('starts WSL session bridging for the selected direct account home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -245,14 +190,6 @@ describe('CodexRuntimeHomeService', () => { startWslCodexSessionBridgeInBackground })) const wslHome = join(testState.userDataDir, 'debian-wsl-home') - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => null, getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null) @@ -262,11 +199,10 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate === wslRuntimeHomePath || candidate.includes('codex-accounts/debian-account/home') ? { distro: 'Debian', - linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' + linuxPath: '/home/alice/.local/share/orca/codex-accounts/debian-account/home' } : null } diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index dbf0241a155..8e8965f6a40 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -53,15 +53,6 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const syncWslRuntime = vi.spyOn( - service as unknown as { - syncWslRuntimeForCurrentSelection: (target: { - runtime: 'wsl' - wslDistro?: string | null - }) => string | null - }, - 'syncWslRuntimeForCurrentSelection' - ) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } const expectedHome = join(wslHome, '.codex') @@ -73,7 +64,6 @@ describe('CodexRuntimeHomeService', () => { kind: 'ready', codexHomePath: expectedHome }) - expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index f61272de8c3..6667e3fd608 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -1,6 +1,4 @@ -/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync - * with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which - * builds the UNC twin of this path. */ +/** Guest-relative layout of Orca's retired WSL CODEX_HOME, retained for migration reads. */ export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [ '.local', 'share', diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index befefb5a2c8..e226814ba84 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -17,7 +17,6 @@ main/agent-hooks/wsl-hook-relay-launch.ts main/browser/wsl-browser-network-relay-launch.ts main/claude-accounts/claude-command-process.ts -main/codex-accounts/runtime-home-service.ts main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 03e06eb374f..dbe9c0c2aa4 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -41,7 +41,6 @@ src/main/browser/browser-cookie-import.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts src/main/claude-accounts/keychain.ts -src/main/codex-accounts/runtime-home-service.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-session.ts From 0f522c35e58acad011c925e33c76849c3d7eeb41 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:30:48 -0700 Subject: [PATCH 007/118] fix(remote): gate empty session inventory on host authority (#16546) --- config/reliability-gates.jsonc | 17 +- src/cli/runtime/websocket-transport.test.ts | 2 + .../terminal-host-agent-session.test.ts | 43 ++ src/main/runtime/orca-runtime.ts | 190 +++++++- .../session-tabs-inventory-rpc.test.ts | 402 +++++++++++++++++ .../rpc/methods/session-tabs-inventory.ts | 110 +++++ .../runtime/rpc/methods/session-tabs.test.ts | 2 + src/main/runtime/rpc/methods/session-tabs.ts | 82 +--- ...session-tabs-inventory-publication.test.ts | 416 ++++++++++++++++++ .../direct-ssh-hydration-fanout.test.ts | 3 +- .../src/runtime/host-live-terminal-probe.ts | 14 +- ...sion-mirror-empty-inventory-settle.test.ts | 284 +++++++++++- ...t-session-mirror-frame-ordering-harness.ts | 6 + ...n-mirror-hydration-frame-ordering.test.tsx | 18 + .../host-session-mirror-settle-census.test.ts | 6 +- ...sion-mirror-settle-receipt-frames.test.tsx | 31 ++ ...runtime-browser-capability-cleanup.test.ts | 1 + ...e-session-browser-client-placement.test.ts | 2 + ...ime-session-browser-create-failure.test.ts | 2 + ...ntime-session-browser-create-focus.test.ts | 2 + ...ion-browser-create-split-placement.test.ts | 2 + ...ession-browser-create-staged-focus.test.ts | 2 + ...owser-create-staged-hosting-intent.test.ts | 2 + ...ime-session-browser-create-staging.test.ts | 2 + ...ession-browser-placement-staleness.test.ts | 2 + ...runtime-session-tab-activate-close.test.ts | 2 + .../web-runtime-session-tab-move.test.ts | 2 + .../web-runtime-session-tab-props.test.ts | 2 + ...me-session-terminal-host-authority.test.ts | 2 + ...ime-session-terminal-legacy-create.test.ts | 2 + ...e-session-terminal-pane-delegation.test.ts | 2 + .../src/runtime/web-runtime-session.test.ts | 2 + .../src/runtime/web-runtime-session.ts | 21 +- ...ssion-tabs-sync-window-visibility.test.tsx | 8 +- .../src/runtime/web-session-tabs-sync.ts | 245 +++++++++-- .../src/store/slices/runtime-status.ts | 7 + src/shared/protocol-version.ts | 3 + .../remote-runtime-client-capabilities.ts | 2 + ...minal-host-restart-background-sync.spec.ts | 9 + ...empty-inventory-daemon-oracle.unit.test.ts | 236 ++++++++++ 40 files changed, 2045 insertions(+), 143 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tabs-inventory.ts create mode 100644 src/main/runtime/session-tabs-inventory-publication.test.ts create mode 100644 tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 8a6dc2b0d96..ac37e3760b9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -8124,6 +8124,7 @@ "hidden remote worktree retention", "remote terminal reveal and input", "paired host relaunch with preserved daemon PTYs", + "paired host session inventory publication authority", "manual server disconnect" ], "platforms": ["macos", "linux", "windows"], @@ -8135,13 +8136,14 @@ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. An empty headed session-tab inventory is authoritative only after the current renderer graph generation publishes a complete inventory and every execution host answers the PTY census; unpublished, resync-pending, transport-failed, and partially scoped inventories remain unverifiable, while genuinely authoritative empty and non-empty inventories settle and a scope-less legacy empty keeps its pre-authority best-effort settle so outdated hosts retain agent auto-resume. The unpublished-empty regression must keep the resume dispatch parked with exactly one daemon process/writer; restoring unconditional empty hydration must release one dispatch and create two live writers. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, hide all six warm managers, emit an initial working title followed by 30 timed real-PTY spinner frames and a final idle title per worktree, and observe a second real session-tabs subscription plus the production renderer store. Require exactly two transport envelopes and two title mutations per worktree, no decorative title crossing either boundary, and positive decoded-envelope bytes. Enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 24 real PTY title trackers in distinct worktrees through 40 decorative spinner frames each, then 40 spinner frames alternating with Cursor's bare native identity redraw, using fake clocks. Require all 2,880 raw chunks exactly and zero host session-tabs publications, serialized bytes, renderer apply calls, and renderer store mutations. Continue decorative evidence beyond 30 minutes, require exactly one refresh per worktree per 15-minute lease through a FIFO spaced by at least 50 ms, and require all 24 viewer statuses to remain working and fresh; then require one exact semantic idle transition and one visible-output chunk per PTY. Require ordinary and interior-Braille title changes to publish. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/session-tabs-inventory-publication.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts src/main/daemon/terminal-host-agent-session.test.ts tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", @@ -8174,6 +8176,11 @@ "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts", "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "src/main/runtime/session-tabs-inventory-publication.test.ts", + "src/main/runtime/rpc/methods/session-tabs.test.ts", + "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts", + "src/main/daemon/terminal-host-agent-session.test.ts", + "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts", "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts", "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts", @@ -8257,6 +8264,14 @@ "file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "assertions": ["retries an existing subscriber when provider inventory becomes ready"] }, + { + "file": "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts", + "assertions": [ + "unpublished empty inventory keeps resume dispatch at zero with the original daemon session, process id, and writer as the sole live writer", + "an authoritative empty inventory settles the mirror and releases exactly one resume dispatch with zero liveness probes", + "re-enabling unconditional empty hydration makes the identical oracle red with one resume dispatch, two session ids, two process ids, and two writer calls" + ] + }, { "file": "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", "assertions": [ diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 985953fd4a9..20328a9b614 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -22,6 +22,7 @@ import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, @@ -69,6 +70,7 @@ describe('CLI remote WebSocket transport', () => { expect.objectContaining({ clientCapabilities: [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, diff --git a/src/main/daemon/terminal-host-agent-session.test.ts b/src/main/daemon/terminal-host-agent-session.test.ts index b087e579ae4..1cf929fb93f 100644 --- a/src/main/daemon/terminal-host-agent-session.test.ts +++ b/src/main/daemon/terminal-host-agent-session.test.ts @@ -90,6 +90,49 @@ describe('TerminalHost agent-session claims', () => { expect(spawnSubprocess).toHaveBeenCalledOnce() }) + it('cannot adopt a live session that predates provider-session claims', async () => { + const subprocesses: ReturnType[] = [] + host = new TerminalHost({ + spawnSubprocess: () => { + const handle = createClaimedSubprocess() + subprocesses.push(handle) + return handle + } + }) + await host.createOrAttach({ + sessionId: 'renderer-owned-live-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const resumed = await host.createOrAttach({ + sessionId: 'replacement-resume-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() }, + agentSessionEnsure: { claim, surface } + }) + + expect(resumed.agentSessionEnsure?.disposition).toBe('created') + expect( + host + .listSessions() + .map(({ sessionId }) => sessionId) + .sort() + ).toEqual(['renderer-owned-live-session', 'replacement-resume-session']) + expect(subprocesses).toHaveLength(2) + host.write('renderer-owned-live-session', 'original-writer') + host.write('replacement-resume-session', 'replacement-writer') + expect(subprocesses[0]?.write).toHaveBeenCalledWith('original-writer') + expect(subprocesses[1]?.write).toHaveBeenCalledWith('replacement-writer') + for (const handle of subprocesses) { + handle.exit() + } + }) + it('rejects a competing claim without replacing the winning stream', async () => { let releaseSpawn: () => void = () => {} const spawnGate = new Promise((resolve) => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b8196482870..c33d741bc29 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -655,6 +655,7 @@ import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' @@ -3254,7 +3255,8 @@ export class OrcaRuntimeService { // reconnect-scan bounding for sequential soft freezes. private readonly terminalFocusNavigationCoalescer = new TerminalFocusNavigationCoalescer() - private pendingMobileSessionPtyInventoryRefresh: Promise | null> | null = null + private pendingMobileSessionPtyAggregateInventoryRefresh: Promise | null = + null private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -3269,6 +3271,8 @@ export class OrcaRuntimeService { private syntheticTerminalHandles = new Set() private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] + private sessionTabsInventoryPublicationEpoch: number | null = null + private sessionTabsInventoryWaiters = new Set<() => void>() private waitersByHandle = new Map>() private ptyExitListenersByPtyId = new Map void>>() private ptyController: RuntimePtyController | null = null @@ -6449,7 +6453,9 @@ export class OrcaRuntimeService { (process.env.ORCA_E2E_DISABLE_RUNTIME_SHARED_CONTROL !== '1' || capability !== REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) && (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || - capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) + capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) && + (process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' || + capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) ) if (hasOffscreen || hasHeadlessCommands) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) @@ -7327,7 +7333,19 @@ export class OrcaRuntimeService { this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) } } + // Why: only the authoritative window grants inventory authority; headless qualifies because it becomes authoritative before its next sync. + const isAuthoritativeGraphPublisher = windowId === this.authoritativeWindowId this.markGraphReady(windowId) + if ( + isAuthoritativeGraphPublisher && + (windowId === HEADLESS_RUNTIME_WINDOW_ID || graph.mobileSessionTabs !== undefined) + ) { + if (mobileSessionResyncWorktrees.size === 0) { + this.markSessionTabsInventoryPublished() + } else { + this.sessionTabsInventoryPublicationEpoch = null + } + } if (rendererGeneration !== undefined) { this.rendererGeneration = rendererGeneration } @@ -7439,6 +7457,13 @@ export class OrcaRuntimeService { async listAllMobileSessionTabs( clientNavigationId?: string ): Promise { + return (await this.collectAllMobileSessionTabs(clientNavigationId)).snapshots + } + + private async collectAllMobileSessionTabs(clientNavigationId?: string): Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + ptyInventory: PtyControllerInventory | null + }> { for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -7446,13 +7471,132 @@ export class OrcaRuntimeService { }) } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() - await this.refreshMobileSessionPtyRecords() - return [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => - this.projectMobileSessionTabsForClient( - this.toMobileSessionTabsResult(snapshot), - clientNavigationId - ) - ) + const ptyInventory = await this.refreshMobileSessionPtyInventory() + this.restoreLivePairedRendererSessionOwnedMobileTerminals(null) + return { + snapshots: [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => + this.projectMobileSessionTabsForClient( + this.toMobileSessionTabsResult(snapshot), + clientNavigationId + ) + ), + ptyInventory + } + } + + async listAllMobileSessionTabsInventory( + clientNavigationId?: string, + signal?: AbortSignal + ): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> { + this.assertSessionTabsInventoryRequestActive(signal) + const primedPublicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() + const primed = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + if ( + primedPublicationEpoch !== null && + this.getAuthoritativeSessionTabsInventoryEpoch() === primedPublicationEpoch + ) { + return await this.settleSessionTabsInventory(primed, clientNavigationId, signal) + } + while (true) { + const publicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() + if (publicationEpoch === null) { + await this.waitForSessionTabsInventoryPublication(signal) + continue + } + const inventory = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + if (this.getAuthoritativeSessionTabsInventoryEpoch() === publicationEpoch) { + return await this.settleSessionTabsInventory(inventory, clientNavigationId, signal) + } + } + } + + // Why: a failed census only invalidates the emptiness verdict, never the + // list. An incomplete census usually means a concurrent scan invalidated + // this one mid-relaunch and daemon-backed tabs could not be restored yet, so + // retry the collection once; a still-incomplete retry serves its snapshots + // unlabeled rather than erroring the request. + private async settleSessionTabsInventory( + inventory: { + snapshots: RuntimeMobileSessionTabsResult[] + ptyInventory: PtyControllerInventory | null + }, + clientNavigationId?: string, + signal?: AbortSignal + ): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> { + if (this.isCompleteSessionTabsPtyCensus(inventory.ptyInventory)) { + return { snapshots: inventory.snapshots, authoritative: true } + } + const retried = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + // Why: the retry ran outside the epoch fence, so it never claims authority. + return { snapshots: retried.snapshots } + } + + supportsAuthoritativeSessionTabsInventory(): boolean { + return process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' + } + + private assertSessionTabsInventoryRequestActive(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('client_disconnected') + } + } + + private isCompleteSessionTabsPtyCensus(inventory: PtyControllerInventory | null): boolean { + if (!inventory) { + return false + } + const knownHostIds = this.listKnownExecutionHostIds(inventory.queriedHostIds) + return ![...knownHostIds].some((hostId) => { + const parsed = parseExecutionHostId(hostId) + return parsed?.kind !== 'runtime' && !inventory.queriedHostIds.has(hostId) + }) + } + + private waitForSessionTabsInventoryPublication(signal?: AbortSignal): Promise { + if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + const cleanup = (): void => { + this.sessionTabsInventoryWaiters.delete(onPublished) + signal?.removeEventListener('abort', onAbort) + } + const onPublished = (): void => { + cleanup() + resolve() + } + const onAbort = (): void => { + cleanup() + reject(new Error('client_disconnected')) + } + this.sessionTabsInventoryWaiters.add(onPublished) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } else if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { + onPublished() + } + }) + } + + private getAuthoritativeSessionTabsInventoryEpoch(): number | null { + return this.graphStatus === 'ready' && + this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch + ? this.rendererGraphEpoch + : null + } + + private markSessionTabsInventoryPublished(): void { + if (this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch) { + return + } + this.sessionTabsInventoryPublicationEpoch = this.rendererGraphEpoch + for (const publish of [...this.sessionTabsInventoryWaiters]) { + publish() + } } private hydrateHeadlessMobileSessionTabsFromWorkspaceSession( @@ -9185,19 +9329,29 @@ export class OrcaRuntimeService { private async refreshMobileSessionPtyRecords( targetWorktreeId: string | null = null ): Promise | null> { + const inventory = await this.refreshMobileSessionPtyInventory(targetWorktreeId) + return inventory ? new Set(inventory.livePtyIds) : null + } + + private async refreshMobileSessionPtyInventory( + targetWorktreeId: string | null = null + ): Promise { if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - const pending = this.pendingMobileSessionPtyInventoryRefresh + // Non-floating refreshes all query the aggregate controller inventory; + // coalesce targeted and all-worktree callers so they cannot invalidate + // one another through the shared aggregate generation fence. + const pending = this.pendingMobileSessionPtyAggregateInventoryRefresh if (pending) { return pending } // Why: reconnect exit bursts share one authoritative daemon inventory // instead of multiplying a full cross-generation list RPC per stale tab. const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => { - if (this.pendingMobileSessionPtyInventoryRefresh === refresh) { - this.pendingMobileSessionPtyInventoryRefresh = null + if (this.pendingMobileSessionPtyAggregateInventoryRefresh === refresh) { + this.pendingMobileSessionPtyAggregateInventoryRefresh = null } }) - this.pendingMobileSessionPtyInventoryRefresh = refresh + this.pendingMobileSessionPtyAggregateInventoryRefresh = refresh return refresh } return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) @@ -9205,14 +9359,14 @@ export class OrcaRuntimeService { private async performMobileSessionPtyRecordsRefresh( targetWorktreeId: string | null - ): Promise | null> { + ): Promise { if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { return null } // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees() - return await this.refreshPtyWorktreeRecordsFromController( + return await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, isFloatingWorkspace ? targetWorktreeId : null ) @@ -31730,6 +31884,8 @@ export class OrcaRuntimeService { return false } if (fence.recovery === 'renderer') { + const restoresPublishedInventory = + this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch - 1 this.graphStatus = 'ready' this.setTerminalSideEffectConsumerAvailable(true) for (const leaf of this.leaves.values()) { @@ -31737,6 +31893,9 @@ export class OrcaRuntimeService { } this.reconcilePtyIncarnationHandles() this.refreshWritableFlags() + if (restoresPublishedInventory) { + this.markSessionTabsInventoryPublished() + } return true } this.graphReloadLifecycle.begin(windowId) @@ -31849,6 +32008,7 @@ export class OrcaRuntimeService { this.clearPtyIncarnationHandles() this.rejectAllWaiters('terminal_handle_stale') this.refreshWritableFlags() + this.markSessionTabsInventoryPublished() } private assertGraphReady(): void { diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts b/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts new file mode 100644 index 00000000000..2834c55b36d --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { OrcaRuntimeService } from '../../orca-runtime' +import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { SESSION_TAB_METHODS } from './session-tabs' +import { listSessionTabsInventory } from './session-tabs-inventory' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('session tabs inventory RPC methods', () => { + it('withholds an old-client list until the host inventory is authoritative', async () => { + let resolveInventory!: (value: { snapshots: []; authoritative: true }) => void + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn( + () => + new Promise<{ snapshots: []; authoritative: true }>((resolve) => { + resolveInventory = resolve + }) + ) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { clientKind: 'runtime' } + ) + for (let index = 0; index < 20 && !resolveInventory; index += 1) { + await Promise.resolve() + } + expect(messages).toEqual([]) + + resolveInventory({ snapshots: [], authoritative: true }) + await pending + + expect(JSON.parse(messages[0]!).result).toEqual({ snapshots: [] }) + }) + + // Why: a census failure only invalidates the emptiness verdict — the runtime + // degrades to the same scan unlabeled, so a capable client keeps the list + // (e.g. one configured-but-disconnected SSH host) without a second collect. + it('serves a capable client an unlabeled list when the census fails', async () => { + const legacyListAll = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => ({ + snapshots: [ + { + worktree: 'wt-local', + publicationEpoch: 'epoch-local', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + })), + listAllMobileSessionTabs: legacyListAll + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { + clientKind: 'runtime', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + + expect(legacyListAll).not.toHaveBeenCalled() + const result = JSON.parse(messages[0]!).result + expect(result.snapshots).toEqual([expect.objectContaining({ worktree: 'wt-local' })]) + expect(result.authoritative).toBeUndefined() + }) + + it('propagates disconnect errors to legacy clients', async () => { + const legacyListAll = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => { + throw new Error('client_disconnected') + }), + listAllMobileSessionTabs: legacyListAll + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { clientKind: 'runtime' } + ) + + expect(legacyListAll).not.toHaveBeenCalled() + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: expect.objectContaining({ code: 'runtime_error' }) + }) + }) + + it('labels an authoritative inventory only for a client that negotiated it', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => ({ + snapshots: [], + authoritative: true as const + })) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { + clientKind: 'runtime', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + + expect(JSON.parse(messages[0]!).result).toEqual({ snapshots: [], authoritative: true }) + }) + + it('serves an old client the degraded scan when terminal liveness cannot be proven', async () => { + const legacyListAll = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => ({ + snapshots: [ + { + worktree: 'wt-local', + publicationEpoch: 'epoch-local', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + })), + listAllMobileSessionTabs: legacyListAll + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { clientKind: 'runtime' } + ) + + expect(legacyListAll).not.toHaveBeenCalled() + expect(JSON.parse(messages[0]!).result).toEqual({ + snapshots: [expect.objectContaining({ worktree: 'wt-local' })] + }) + }) + + it('forwards the caller signal so a disconnected request never scans', async () => { + const inventory = vi.fn(async (_navigationId?: string, signal?: AbortSignal) => { + if (signal?.aborted) { + throw new Error('client_disconnected') + } + return { snapshots: [] } + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: inventory + } as unknown as OrcaRuntimeService + const controller = new AbortController() + controller.abort() + + await expect(listSessionTabsInventory({ runtime, signal: controller.signal })).rejects.toThrow( + 'client_disconnected' + ) + expect(inventory).toHaveBeenCalledWith(undefined, controller.signal) + }) + + it('lets the final authoritative inventory subsume prior-epoch updates', async () => { + let resolveInventory!: (value: { + snapshots: RuntimeMobileSessionTabsResult[] + authoritative: true + }) => void + const listeners: ((snapshot: RuntimeMobileSessionTabsResult) => void)[] = [] + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn( + () => + new Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + authoritative: true + }>((resolve) => { + resolveInventory = resolve + }) + ), + onMobileSessionTabsChanged: vi.fn( + (listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => { + listeners.push(listener) + return vi.fn() + } + ), + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { + connectionId: 'conn-authoritative', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + await Promise.resolve() + listeners[0]?.({ + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-before-reload', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + expect(messages).toEqual([]) + + resolveInventory({ + snapshots: [ + { + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ], + authoritative: true + }) + await pending + listeners[0]?.({ + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + + expect(messages.map((message) => JSON.parse(message).result)).toEqual([ + { + type: 'snapshots', + snapshots: [ + expect.objectContaining({ + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 1 + }) + ], + authoritative: true + }, + expect.objectContaining({ + type: 'updated', + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 2 + }) + ]) + }) + + it('lets authoritative empty inventory win over prior-epoch updates', async () => { + let resolveInventory!: (value: { snapshots: []; authoritative: true }) => void + const listeners: ((snapshot: RuntimeMobileSessionTabsResult) => void)[] = [] + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn( + () => + new Promise<{ snapshots: []; authoritative: true }>((resolve) => { + resolveInventory = resolve + }) + ), + onMobileSessionTabsChanged: vi.fn( + (listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => { + listeners.push(listener) + return vi.fn() + } + ), + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + const snapshot = (snapshotVersion: number): RuntimeMobileSessionTabsResult => ({ + worktree: 'wt-buffered', + publicationEpoch: 'epoch-buffered', + snapshotVersion, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { + connectionId: 'conn-authoritative-empty', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + await Promise.resolve() + listeners[0]?.(snapshot(2)) + listeners[0]?.(snapshot(3)) + + resolveInventory({ snapshots: [], authoritative: true }) + await pending + + expect(messages.map((message) => JSON.parse(message).result)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true } + ]) + }) + + it('does no subscription work for an already-cancelled stream', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(), + onMobileSessionTabsChanged: vi.fn(), + registerSubscriptionCleanup: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const controller = new AbortController() + const messages: string[] = [] + controller.abort() + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { signal: controller.signal } + ) + + expect(runtime.onMobileSessionTabsChanged).not.toHaveBeenCalled() + expect(runtime.listAllMobileSessionTabsInventory).not.toHaveBeenCalled() + expect(messages.map((message) => JSON.parse(message))).toEqual([ + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ code: 'runtime_error' }) + }) + ]) + }) + + it('aborts and removes a publication waiter when the stream is cleaned up', async () => { + const runtime = new OrcaRuntimeService() + runtime.attachWindow(1) + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { + connectionId: 'conn-cancel', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + const waiters = (runtime as unknown as { sessionTabsInventoryWaiters: Set<() => void> }) + .sessionTabsInventoryWaiters + for (let index = 0; index < 20 && waiters.size === 0; index += 1) { + await Promise.resolve() + } + expect(waiters.size).toBe(1) + + runtime.cleanupSubscription('session.tabs:conn-cancel:*:req-1') + await pending + + expect(waiters.size).toBe(0) + expect(messages.map((message) => JSON.parse(message))).toEqual([ + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ code: 'runtime_error' }) + }) + ]) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts new file mode 100644 index 00000000000..f47c2251f6e --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -0,0 +1,110 @@ +import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { RpcContext } from '../core' +import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' +import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' + +type SessionTabsInventory = { + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: true +} + +function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean { + return ( + context.clientCapabilities?.includes( + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY + ) === true + ) +} + +export function projectSessionTabsForClient( + snapshot: RuntimeMobileSessionTabsResult, + clientKind: 'mobile' | 'runtime' | undefined, + clientCapabilities: Parameters[2] +): RuntimeMobileSessionTabsResult { + return projectSessionTabBrowserPlacements( + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), + clientCapabilities + ) +} + +function projectInventory( + inventory: SessionTabsInventory, + context: RpcContext +): SessionTabsInventory { + return { + snapshots: inventory.snapshots.map((snapshot) => + projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities) + ), + ...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context) + ? { authoritative: true as const } + : {}) + } +} + +export async function listSessionTabsInventory(context: RpcContext): Promise { + const { runtime, pairedDeviceId, signal } = context + // Why: a failed census degrades inside the runtime to the same scan without + // the authoritative label, so no client ever pays a second full collection. + const inventory = runtime.supportsAuthoritativeSessionTabsInventory() + ? await runtime.listAllMobileSessionTabsInventory(pairedDeviceId, signal) + : { snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) } + return projectInventory(inventory, context) +} + +export async function subscribeSessionTabsInventory( + context: RpcContext, + emit: (result: unknown) => void +): Promise { + const { runtime, connectionId, requestId, pairedDeviceId } = context + if (context.signal?.aborted) { + throw new Error('client_disconnected') + } + const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` + const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix + const inventoryController = new AbortController() + const abortInventory = (): void => inventoryController.abort() + context.signal?.addEventListener('abort', abortInventory, { once: true }) + let initialized = false + let closed = false + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + if (!initialized) { + // Why: the final authoritative scan subsumes these; replaying a prior epoch afterward resurrects stale host state. + return + } + emit({ + type: 'updated', + ...projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities) + }) + }, pairedDeviceId) + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + inventoryController.abort() + unsubscribe() + if (initialized) { + emit({ type: 'end' }) + } + }, + connectionId + ) + if (closed) { + context.signal?.removeEventListener('abort', abortInventory) + return + } + const inventory = await listSessionTabsInventory({ + ...context, + signal: inventoryController.signal + }) + .catch((error) => { + runtime.cleanupSubscription(subscriptionId) + throw error + }) + .finally(() => context.signal?.removeEventListener('abort', abortInventory)) + if (closed) { + return + } + emit({ type: 'snapshots', ...inventory }) + initialized = true +} diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 488a9da35a3..18f130bd869 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -651,6 +651,7 @@ describe('session tab RPC methods', () => { tabs: [] } ]), + supportsAuthoritativeSessionTabsInventory: vi.fn(() => false), onMobileSessionTabsChanged: vi.fn((listener: (snapshot: unknown) => void) => { listeners.push(listener) return unsubscribe @@ -697,6 +698,7 @@ describe('session tab RPC methods', () => { const runtime = { getRuntimeId: () => 'test-runtime', listAllMobileSessionTabs: vi.fn(() => []), + supportsAuthoritativeSessionTabsInventory: vi.fn(() => false), onMobileSessionTabsChanged: vi.fn(() => vi.fn()), registerSubscriptionCleanup: vi.fn() } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 1f459debd50..5b9958aeee8 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -7,23 +7,14 @@ import { WorktreeTabSelector } from './session-tabs-schemas' import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods' -import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' -import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' -import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + listSessionTabsInventory, + projectSessionTabsForClient, + subscribeSessionTabsInventory +} from './session-tabs-inventory' import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods' import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' -function projectSessionTabsForClient( - snapshot: RuntimeMobileSessionTabsResult, - clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: Parameters[2] -) { - return projectSessionTabBrowserPlacements( - projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), - clientCapabilities - ) -} - export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', @@ -38,11 +29,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => ({ - snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) => - projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) - ) - }) + handler: async (_params, context) => listSessionTabsInventory(context) }), ...SESSION_TAB_MUTATION_METHODS, ...SESSION_TAB_CLOSE_METHODS, @@ -155,62 +142,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async ( - _params, - { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, - emit - ) => { - let unsubscribe = (): void => {} - let closed = false - // Why: initial listAll errors should return one RPC error, not a leaked - // subscription cleanup that later emits a stray end frame. - let initialized = false - const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` - const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix - // Why: shared-control can carry multiple all-tab subscribers on one - // socket; include the RPC id so closing one does not evict siblings. - runtime.registerSubscriptionCleanup( - subscriptionId, - () => { - closed = true - unsubscribe() - if (initialized) { - emit({ type: 'end' }) - } - }, - connectionId - ) - - if (closed) { - return - } - const snapshots = await Promise.resolve( - runtime.listAllMobileSessionTabs(pairedDeviceId) - ).catch((error) => { - runtime.cleanupSubscription(subscriptionId) - throw error - }) - if (closed) { - return - } - emit({ - type: 'snapshots', - snapshots: snapshots.map((snapshot) => - projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) - ) - }) - initialized = true - - if (closed) { - return - } - unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emit({ - type: 'updated', - ...projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) - }) - }, pairedDeviceId) - } + handler: async (_params, context, emit) => subscribeSessionTabsInventory(context, emit) }), defineMethod({ name: 'session.tabs.unsubscribeAll', diff --git a/src/main/runtime/session-tabs-inventory-publication.test.ts b/src/main/runtime/session-tabs-inventory-publication.test.ts new file mode 100644 index 00000000000..761504c273f --- /dev/null +++ b/src/main/runtime/session-tabs-inventory-publication.test.ts @@ -0,0 +1,416 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +type InventoryInternals = { + sessionTabsInventoryWaiters: Set<() => void> +} + +function createInventoryRuntime(): OrcaRuntimeService { + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ listProcesses: vi.fn(async () => []) } as never) + return runtime +} + +async function waitForInventoryWaiter(runtime: OrcaRuntimeService): Promise { + const internals = runtime as unknown as InventoryInternals + for (let index = 0; index < 20 && internals.sessionTabsInventoryWaiters.size === 0; index += 1) { + await Promise.resolve() + } + expect(internals.sessionTabsInventoryWaiters.size).toBe(1) +} + +describe('authoritative session tab inventory publication', () => { + it('primes daemon-backed inventory but withholds headed results until renderer publication', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + let settled = false + + const pending = runtime.listAllMobileSessionTabsInventory().then((result) => { + settled = true + return result + }) + await waitForInventoryWaiter(runtime) + + expect(collect).toHaveBeenCalledTimes(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + await Promise.resolve() + expect(settled).toBe(false) + + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + expect(collect).toHaveBeenCalledTimes(2) + }) + + it('treats an initial headless graph as an authoritative empty publication', async () => { + const runtime = createInventoryRuntime() + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('returns a non-empty headed publication normally', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: 'repo::/worktree', + publicationEpoch: 'renderer-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + await expect(pending).resolves.toMatchObject({ + authoritative: true, + snapshots: [{ worktree: 'repo::/worktree', publicationEpoch: 'renderer-epoch' }] + }) + }) + + it('uses one inventory scan when the current publication is already authoritative', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ + snapshots: [], + authoritative: true + }) + + expect(collect).toHaveBeenCalledOnce() + }) + + it('retries publication churn until one inventory observes a stable epoch', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const inventory = { + snapshots: [], + ptyInventory: { + livePtyIds: new Set(), + allLivePtyIds: new Set(), + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set(['local']) + } + } + let collections = 0 + vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ).mockImplementation(async () => { + collections += 1 + if (collections <= 3) { + expect(runtime.markRendererReloading(1)).not.toBeNull() + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + } + return inventory + }) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ + snapshots: [], + authoritative: true + }) + expect(collections).toBe(4) + }) + + it('coalesces targeted and all-host PTY refreshes behind one aggregate census', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const emptyInventory = { + livePtyIds: new Set(), + allLivePtyIds: new Set(), + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set(['local']) + } + let resolveRefresh: ((inventory: typeof emptyInventory) => void) | undefined + const internals = runtime as unknown as { + refreshMobileSessionPtyInventory: (targetWorktreeId?: string | null) => Promise + performMobileSessionPtyRecordsRefresh: (targetWorktreeId: string | null) => Promise + } + const perform = vi.spyOn(internals, 'performMobileSessionPtyRecordsRefresh').mockImplementation( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + + const allHosts = runtime.listAllMobileSessionTabsInventory() + await Promise.resolve() + const targeted = internals.refreshMobileSessionPtyInventory('repo::/target') + await Promise.resolve() + + expect(perform).toHaveBeenCalledOnce() + resolveRefresh?.(emptyInventory) + + await targeted + await expect(allHosts).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('retries once and serves an unlabeled scan when the PTY census is unavailable', async () => { + const runtime = new OrcaRuntimeService() + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ snapshots: [] }) + // Why: the retry gives daemon-backed tabs a post-race restore chance. + expect(collect).toHaveBeenCalledTimes(2) + }) + + it('serves an unlabeled scan when an execution host is omitted from the census', async () => { + const runtime = createInventoryRuntime() + vi.spyOn( + runtime as unknown as { + listKnownExecutionHostIds: () => Set + }, + 'listKnownExecutionHostIds' + ).mockReturnValue(new Set(['local', 'ssh:box-1'])) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ snapshots: [] }) + }) + + it('restores a renderer-owned PTY discovered by the authoritative inventory refresh', async () => { + const runtime = createInventoryRuntime() + const ptyId = 'pty-survived-host-relaunch' + const worktreeId = 'repo::/survived' + const tabId = '11111111-1111-4111-8111-111111111111' + const leafId = '22222222-2222-4222-8222-222222222222' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + ;( + runtime as unknown as { + pairedRendererSessionOwnedPtyIds: Set + } + ).pairedRendererSessionOwnedPtyIds.add(ptyId) + vi.spyOn( + runtime as unknown as { refreshMobileSessionPtyInventory: () => Promise }, + 'refreshMobileSessionPtyInventory' + ).mockImplementation(async () => { + runtime.registerPty(ptyId, worktreeId, null, { + tabId, + leafId, + incarnationId: 'incarnation-survived-host-relaunch' + }) + return { + livePtyIds: new Set([ptyId]), + allLivePtyIds: new Set([ptyId]), + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set(['local']) + } + }) + + const result = await runtime.listAllMobileSessionTabsInventory() + + expect(result).toMatchObject({ + authoritative: true, + snapshots: [ + { + worktree: worktreeId, + tabs: [ + expect.objectContaining({ + type: 'terminal', + parentTabId: tabId, + leafId, + ptyId + }) + ] + } + ] + }) + }) + + it('requires the current headed renderer generation to publish', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [] + }) + runtime.markRendererReloading(1) + let settled = false + + const pending = runtime.listAllMobileSessionTabsInventory().then((result) => { + settled = true + return result + }) + await waitForInventoryWaiter(runtime) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + await Promise.resolve() + expect(settled).toBe(false) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: 'repo::/restored', + publicationEpoch: 'renderer-reloaded', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + await expect(pending).resolves.toMatchObject({ + authoritative: true, + snapshots: [{ worktree: 'repo::/restored', publicationEpoch: 'renderer-reloaded' }] + }) + }) + + it('withholds authority while the renderer owes a requested worktree resync', async () => { + const runtime = createInventoryRuntime() + const worktree = 'repo::/resync' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + + const incomplete = runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [], + unchangedMobileSessionWorktrees: [worktree] + }) + expect(incomplete.mobileSessionResyncWorktrees).toEqual([worktree]) + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree, + publicationEpoch: 'renderer-resynced', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + await expect(pending).resolves.toMatchObject({ + authoritative: true, + snapshots: [{ worktree, publicationEpoch: 'renderer-resynced' }] + }) + }) + + it('restores publication authority when a renderer reload is cancelled', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const fence = runtime.markRendererReloading(1) + if (!fence) { + throw new Error('expected renderer reload fence') + } + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + expect(runtime.markRendererReloadCancelled(1, fence)).toBe(true) + + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('restores authoritative headless publication after a failed desktop promotion', async () => { + const runtime = createInventoryRuntime() + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + runtime.attachWindow(1) + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.markGraphReloadFailed(1, 'renderer-process-gone') + + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('removes a publication waiter when the request is cancelled', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const controller = new AbortController() + const pending = runtime.listAllMobileSessionTabsInventory(undefined, controller.signal) + await waitForInventoryWaiter(runtime) + + controller.abort() + + await expect(pending).rejects.toThrow('client_disconnected') + expect((runtime as unknown as InventoryInternals).sessionTabsInventoryWaiters.size).toBe(0) + }) + + it('does not scan when the inventory request is already cancelled', async () => { + const runtime = createInventoryRuntime() + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + const controller = new AbortController() + controller.abort() + + await expect( + runtime.listAllMobileSessionTabsInventory(undefined, controller.signal) + ).rejects.toThrow('client_disconnected') + expect(collect).not.toHaveBeenCalled() + }) + + it('does not let a secondary renderer graph publish inventory authority', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + let settled = false + + const pending = runtime.listAllMobileSessionTabsInventory().then((result) => { + settled = true + return result + }) + await waitForInventoryWaiter(runtime) + + expect(() => + runtime.syncWindowGraph(2, { tabs: [], leaves: [], mobileSessionTabs: [] }) + ).toThrow('Runtime graph publisher does not match the authoritative window') + await Promise.resolve() + expect(settled).toBe(false) + expect( + (runtime as unknown as { sessionTabsInventoryPublicationEpoch: number | null }) + .sessionTabsInventoryPublicationEpoch + ).toBeNull() + + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('reports no authoritative support behind the e2e disable override', () => { + vi.stubEnv('ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY', '1') + try { + expect(createInventoryRuntime().supportsAuthoritativeSessionTabsInventory()).toBe(false) + } finally { + vi.unstubAllEnvs() + } + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts index 145300fddc6..e63ce38d2fa 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts @@ -64,8 +64,7 @@ describe('direct SSH hydration fanout', () => { api: { ui: {}, ssh: { - listTargets: () => - Promise.resolve(targets.map((id) => ({ id, label: id }))), + listTargets: () => Promise.resolve(targets.map((id) => ({ id, label: id }))), listRemovedTargetLabels: () => Promise.resolve({}), // A wedged relay answers nothing until the 30s RPC timeout fires. getState: ({ targetId }: { targetId: string }) => { diff --git a/src/renderer/src/runtime/host-live-terminal-probe.ts b/src/renderer/src/runtime/host-live-terminal-probe.ts index 1294fcb539e..8e5d8a14539 100644 --- a/src/renderer/src/runtime/host-live-terminal-probe.ts +++ b/src/renderer/src/runtime/host-live-terminal-probe.ts @@ -19,6 +19,7 @@ type RuntimeCall = (args: { method: string params: unknown timeoutMs: number + expectedEnvironmentPairingRevision?: number }) => Promise> type ValidTerminalListResult = RuntimeTerminalListResult & { @@ -52,7 +53,8 @@ function isTerminalListResult(value: unknown): value is ValidTerminalListResult async function probeHost( environmentId: string, - call: RuntimeCall + call: RuntimeCall, + expectedEnvironmentPairingRevision?: number ): Promise { const response = await call({ selector: environmentId, @@ -66,7 +68,8 @@ async function probeHost( requireFreshPtyLiveness: true, includeVisualLayouts: false }, - timeoutMs: 15_000 + timeoutMs: 15_000, + expectedEnvironmentPairingRevision }) if (response.ok === false || !isTerminalListResult(response.result)) { return 'unverifiable' @@ -86,14 +89,15 @@ async function probeHost( export function probeHostLiveTerminals( environmentId: string, call: RuntimeCall = (args) => window.api.runtimeEnvironments.call(args), - connectionGeneration = 0 + connectionGeneration = 0, + expectedEnvironmentPairingRevision?: number ): Promise { - const key = `${environmentId}\0${connectionGeneration}` + const key = `${environmentId}\0${connectionGeneration}\0${expectedEnvironmentPairingRevision ?? 'unknown'}` const existing = inFlightProbeByEnvironment.get(key) if (existing) { return existing } - const probe = probeHost(environmentId, call) + const probe = probeHost(environmentId, call, expectedEnvironmentPairingRevision) .catch((): HostLiveTerminalProbeVerdict => 'unverifiable') .finally(() => { if (inFlightProbeByEnvironment.get(key) === probe) { diff --git a/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts b/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts index 0d590055917..8c815968eec 100644 --- a/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts @@ -25,7 +25,14 @@ vi.mock('../store', () => ({ } })) -import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' +import { + clearRuntimeEnvironmentConnectionGenerationsForTests, + setRuntimeEnvironmentConnectionGenerationForTests +} from '@/store/slices/runtime-status' +import { + getRuntimeEnvironmentRevision, + replaceRuntimeEnvironmentRevisions +} from './runtime-environment-revision' import { hasHostSessionMirrorHydrated, parkUntilHostSessionMirrorHydrates, @@ -35,11 +42,19 @@ import { clearHostLiveTerminalProbesForTests, probeHostLiveTerminals } from './host-live-terminal-probe' -import { applyWebSessionTabsStorePatch } from './web-session-tabs-sync' +import { + applyWebSessionTabsStorePatch, + clearWebSessionTabsTrackingForEnvironment, + type HostSessionMirrorSettle +} from './web-session-tabs-sync' const WORKTREE = 'repo1::/path/wt1' -type HostCall = { method: string; params: Record } +type HostCall = { + method: string + params: Record + expectedEnvironmentPairingRevision?: number +} /** `null` models a failed probe: `unverifiable`, never `the host has none`. */ function stubPairedHost( @@ -71,11 +86,31 @@ function stubPairedHost( } /** The exact verdict both `listAll` sites build when the host publishes nothing. */ -function settleEmptyInventory(environmentId: string): void { - applyWebSessionTabsStorePatch(() => ({}), { +function settleEmptyInventory( + environmentId: string, + options: { + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } = {} +): void { + createEmptyInventorySettle(environmentId, options)() +} + +function createEmptyInventorySettle( + environmentId: string, + options: { + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } = {} +): HostSessionMirrorSettle { + return applyWebSessionTabsStorePatch(() => ({}), { frames: [], - fullInventory: { environmentId, publishedSnapshotCount: 0 } - })() + fullInventory: { environmentId, publishedSnapshotCount: 0, ...options } + }) } async function flushProbe(): Promise { @@ -87,6 +122,7 @@ describe('empty host inventory settling the session mirror', () => { resetHostSessionMirrorHydrationForTests() clearRuntimeEnvironmentConnectionGenerationsForTests() clearHostLiveTerminalProbesForTests() + replaceRuntimeEnvironmentRevisions([]) }) afterEach(() => { @@ -127,6 +163,94 @@ describe('empty host inventory settling the session mirror', () => { expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true) }) + it('settles an authoritative empty inventory despite an unrelated live terminal', async () => { + const environmentId = 'env-authoritative-empty' + const calls = stubPairedHost(['unrelated-terminal']) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId, { authoritative: true }) + + expect(resumeSweeps).toBe(1) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true) + expect(calls).toEqual([]) + }) + + it('rejects an authoritative receipt after the same id is re-paired', () => { + const environmentId = 'env-authoritative-repaired' + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }]) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + const settle = createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedEnvironmentPairingRevision: 11 + }) + + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }]) + settle() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('rejects an authoritative receipt from the previous connection generation', () => { + const environmentId = 'env-authoritative-reconnected' + setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 1) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + const settle = createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedEnvironmentConnectionGeneration: 1 + }) + + setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 2) + settle() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('rejects stale inventory even when recovery completes after reconnect', () => { + const environmentId = 'env-reconnected-before-receipt' + setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 2) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedEnvironmentConnectionGeneration: 1 + })() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('rejects an authoritative receipt after mirror tracking is cleared', () => { + const environmentId = 'env-authoritative-cleared' + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + const settle = createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedTrackingGeneration: 0 + }) + + clearWebSessionTabsTrackingForEnvironment(environmentId) + settle() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + it('leaves waiters parked when the readiness probe fails', async () => { const environmentId = 'env-probe-failed' stubPairedHost(null) @@ -157,7 +281,7 @@ describe('empty host inventory settling the session mirror', () => { expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) }) - it('accepts a legacy zero-terminal result without host scope', async () => { + it('treats a legacy zero-terminal result without host scope as none', async () => { const result = await probeHostLiveTerminals( 'env-legacy-zero', vi.fn(async () => ({ @@ -189,6 +313,28 @@ describe('empty host inventory settling the session mirror', () => { expect(result).toBe('live') }) + // Why: pre-hostScope hosts cannot prove emptiness; parking them forever would + // strand auto-resume on every truly-empty legacy host, so their best-effort + // empty keeps settling as it did before authoritative inventories existed. + it('settles a scope-less legacy empty inventory', async () => { + const environmentId = 'env-legacy-scope-less' + const call = vi.fn(async () => ({ + ok: true, + result: { terminals: [], totalCount: 0, truncated: false } + })) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId) + await flushProbe() + + expect(resumeSweeps).toBe(1) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true) + }) + it('rejects a present malformed host scope as unverifiable', async () => { const result = await probeHostLiveTerminals( 'env-malformed-scope', @@ -273,4 +419,126 @@ describe('empty host inventory settling the session mirror', () => { await expect(firstProbe).resolves.toBe('live') await expect(secondProbe).resolves.toBe('none') }) + + it('does not let a probe response cross a same-id pairing revision', async () => { + const environmentId = 'env-repaired' + let resolveProbe!: (response: RuntimeRpcResponse) => void + const call = vi.fn( + () => + new Promise>((resolve) => { + resolveProbe = resolve + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }]) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 11 }) + expect(call).toHaveBeenCalledWith( + expect.objectContaining({ expectedEnvironmentPairingRevision: 11 }) + ) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }]) + expect(getRuntimeEnvironmentRevision(environmentId)).toBe(12) + resolveProbe({ + id: 'probe-repaired', + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime' } + }) + await flushProbe() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('does not reuse an in-flight probe across same-id pairing revisions', async () => { + const environmentId = 'env-repaired-inflight' + const responses: ((response: RuntimeRpcResponse) => void)[] = [] + const call = vi.fn( + () => + new Promise>((resolve) => { + responses.push(resolve) + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }]) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 11 }) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }]) + settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 12 }) + expect(call).toHaveBeenCalledTimes(2) + + responses[0]!({ + id: 'probe-old-owner', + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:old'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime-old' } + }) + responses[1]!({ + id: 'probe-new-owner', + ok: true, + result: { + terminals: [{ handle: 'terminal-new-owner' }], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['runtime:new'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime-new' } + }) + await flushProbe() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('invalidates a pending legacy probe when environment tracking is cleared', async () => { + const environmentId = 'env-cleared' + let resolveProbe!: (response: RuntimeRpcResponse) => void + const call = vi.fn( + () => + new Promise>((resolve) => { + resolveProbe = resolve + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId) + clearWebSessionTabsTrackingForEnvironment(environmentId) + resolveProbe({ + id: 'probe-cleared', + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime' } + }) + await flushProbe() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) }) diff --git a/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts b/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts index dd852ec3367..db385639a41 100644 --- a/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts +++ b/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts @@ -4,6 +4,10 @@ import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-envi import { makePaneKey } from '../../../shared/stable-pane-id' import { resetStaleDocumentVisibilityForTesting } from '@/components/terminal-pane/stale-document-visibility' import { useAppStore, type AppState } from '@/store' +import { + clearRuntimeEnvironmentConnectionGenerationsForTests, + setRuntimeEnvironmentConnectionGenerationForTests +} from '@/store/slices/runtime-status' import { BG_MIRROR_TAB_ID, BG_WT, @@ -189,6 +193,7 @@ export function installFrameOrderingHarness(options: { fakeTimers?: boolean } = } resetWebSessionTabsSnapshotFreshnessForTests() resetHostSessionMirrorHydrationForTests() + setRuntimeEnvironmentConnectionGenerationForTests(ENV, 1) seedState() }) @@ -198,6 +203,7 @@ export function installFrameOrderingHarness(options: { fakeTimers?: boolean } = replaceRuntimeEnvironmentRevisions([]) resetWebSessionTabsSnapshotFreshnessForTests() resetHostSessionMirrorHydrationForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() if (options.fakeTimers) { resetStaleDocumentVisibilityForTesting() setDocumentVisibility('visible') diff --git a/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx b/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx index 6c3b7388d63..3bb1355bfad 100644 --- a/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx @@ -68,9 +68,11 @@ import { tabIds } from './host-session-mirror-frame-ordering-harness' import { + clearWebSessionTabsTrackingForEnvironment, useWebSessionTabsSync, WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync' +import { hasHostSessionMirrorHydrated } from './host-session-mirror-hydration' import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking' /** @@ -212,6 +214,22 @@ describe('mirrored-pane resume deferral against real stream frames', () => { expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) expectReplayedResume(paneKey, WT, 'codex-session-scoped-frame') }) + + it('does not let a scoped frame from before tracking reset release its pane', async () => { + renderHook(() => useWebSessionTabsSync()) + await act(settle) + const paneKey = seedSleepingRecord(MIRROR_TAB_ID, WT, 'codex-session-scoped-old-tracking') + expect(resumeSleepingAgentSessionsForWorktree(WT)).toBe(0) + + clearWebSessionTabsTrackingForEnvironment(ENV) + await publish(findSubscription('session.tabs.subscribe'), { + type: 'snapshot', + ...makeHostSnapshot(WT, OTHER_HOST_SURFACE_ID, OTHER_HOST_PARENT_TAB_ID) + }) + + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(hasHostSessionMirrorHydrated(ENV, WT)).toBe(false) + }) }) describe('mirror latch verdicts against real stream failures', () => { diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index dcda5332160..75a88500462 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -104,11 +104,11 @@ describe('host-session-mirror settle census', () => { 'runtime/host-session-mirror-hydration.ts': { hydrated: 1, worktreeHydrated: 1 }, // Exactly the receipt constructors: the patch receipt (environment-wide // and per-worktree) and the stale-frame receipt. The environment-wide - // mark has two audited arms, both inside the receipt: an inventory that - // published snapshots settles on the spot, and one that published none + // mark has three audited arms, all inside the receipt: an authoritative + // empty inventory, a non-empty inventory, and an empty inventory that // settles only after `terminal.list` says the host has no live PTY — // `0 === 0` is not host evidence (STA-5377). - 'runtime/web-session-tabs-sync.ts': { hydrated: 2, worktreeHydrated: 2 } + 'runtime/web-session-tabs-sync.ts': { hydrated: 3, worktreeHydrated: 2 } }) }) diff --git a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx index 4f7050bf9e6..b07bce1052c 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx @@ -70,6 +70,7 @@ import { } from './host-session-mirror-hydration' import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session' import { + clearWebSessionTabsTrackingForEnvironment, useWebSessionTabsSync, WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync' @@ -278,6 +279,36 @@ describe('the eager post-create list answers for its worktree', () => { expect(useAppStore.getState().tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBeUndefined() expect(hasHostSessionMirrorHydrated(ENV, FLOATING_TERMINAL_WORKTREE_ID)).toBe(false) }) + + it('does not let a pre-reset eager list settle in the new tracking epoch', async () => { + let resolveList!: (response: unknown) => void + runtimeCall.mockImplementation((request: { method: string }) => + request.method === 'session.tabs.list' + ? new Promise((resolve) => { + resolveList = resolve + }) + : new Promise(() => {}) + ) + const paneKey = seedSleepingRecord(MIRROR_TAB_ID, WT, 'codex-session-eager-old-tracking') + expect(resumeSleepingAgentSessionsForWorktree(WT)).toBe(0) + + const refresh = refreshWebRuntimeSessionTabsSnapshot(ENV, WT) + await act(settle) + clearWebSessionTabsTrackingForEnvironment(ENV) + resolveList({ + id: 'list', + ok: true as const, + result: makeHostSnapshot(WT, OTHER_HOST_SURFACE_ID, OTHER_HOST_PARENT_TAB_ID), + _meta: { runtimeId: 'runtime-a' } + }) + await act(async () => { + await refresh + await settle() + }) + + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(hasHostSessionMirrorHydrated(ENV, WT)).toBe(false) + }) }) /** diff --git a/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts b/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts index 760f025f57a..bded4a1eb67 100644 --- a/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts +++ b/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts @@ -24,6 +24,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), applyWebSessionTabsStorePatch: vi.fn(() => () => {}), resolveHostSessionTabIdForWebSessionTab: vi.fn() })) diff --git a/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts index 6e7b99757d4..1b5a2bea7b3 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts @@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -49,6 +50,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts index ef959660c5d..505c22aafd4 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -44,6 +45,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts index 9a0294b90e9..b920f9e62bd 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -48,6 +49,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts index fb4b0dfe22f..b348d73548a 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -47,6 +48,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts index edc7aa06a80..2cfd7917cd8 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts index cd469bf4768..17c810c8060 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts index 92263598015..181f8b9a5fc 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -47,6 +48,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts index 74801336fd5..480012c4b96 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) return () => {} diff --git a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts index 42dfa7612dc..9dbcf96d823 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -44,6 +45,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts index f9e1fee9dd4..15ae9bfb1d8 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -37,6 +38,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts index 9585f87fd33..8d645c32174 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -36,6 +37,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts index 209cd182da9..394f67d9e77 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts @@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -49,6 +50,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts index 58c3b369712..6b1ba5e6d7c 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -42,6 +43,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts index d632e8b80fa..b08040c0e66 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -40,6 +41,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 76af63fbd63..675c9f34313 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), acceptReplayedWebSessionTabsSnapshot: vi.fn(), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), deliverLaunchPromptToAgentTab: vi.fn(), @@ -46,6 +47,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 1e443b13b25..c9313bbca7d 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -72,6 +72,7 @@ import { import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' import { translate } from '../i18n/i18n' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { parsePaneKey } from '../../../shared/stable-pane-id' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import { @@ -1074,8 +1075,13 @@ export async function refreshWebRuntimeSessionTabsSnapshot( errorMode?: 'warn' | 'throw' } = {} ): Promise { + const webSessionTabsSync = await import('./web-session-tabs-sync') const expectedEnvironmentPairingRevision = options.expectedEnvironmentPairingRevision ?? getRuntimeEnvironmentRevision(environmentId) + const expectedEnvironmentConnectionGeneration = + getRuntimeEnvironmentConnectionGeneration(environmentId) + const expectedTrackingGeneration = + webSessionTabsSync.getWebSessionTabsTrackingGeneration(environmentId) const callEnvironment = captureRuntimeEnvironmentCall( environmentId, expectedEnvironmentPairingRevision @@ -1124,7 +1130,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot( applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch, decideWebSessionTabsSnapshot - } = await import('./web-session-tabs-sync') + } = webSessionTabsSync if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision) { return } @@ -1140,7 +1146,18 @@ export async function refreshWebRuntimeSessionTabsSnapshot( : state return patch === state ? state : patch }, - { frames: [{ environmentId, worktreeId: snapshot.worktree, decision }] }, + { + frames: [ + { + environmentId, + worktreeId: snapshot.worktree, + decision, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] + }, snapshot ) settleMirror() diff --git a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx index 4d9ab464273..187fb213b3f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx @@ -49,6 +49,7 @@ import { useWebSessionTabsSync, WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync' +import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT, WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS @@ -59,7 +60,7 @@ const ENV_B = 'env-b' const WORKTREE = 'repo-a::worktree-a' const REVISION_A = 101 const REVISION_B = 201 -const MIRROR_KEY = `${ENV_A}\u0001runtime-a\u00011\u0001${REVISION_A}\u0000${ENV_B}\u0001runtime-b\u00012\u0001${REVISION_B}` +const MIRROR_KEY = `${ENV_A}\u0001runtime-a\u00010\u0001${REVISION_A}\u0000${ENV_B}\u0001runtime-b\u00010\u0001${REVISION_B}` const initialState = useAppStore.getInitialState() type RuntimeSubscribe = typeof window.api.runtimeEnvironments.subscribe @@ -238,6 +239,7 @@ describe('useWebSessionTabsSync window visibility', () => { useAppStore.setState(initialState, true) replaceRuntimeEnvironmentRevisions([]) resetWebSessionTabsSnapshotFreshnessForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() resetStaleDocumentVisibilityForTesting() setDocumentVisibility('visible') vi.useRealTimers() @@ -627,7 +629,7 @@ describe('useWebSessionTabsSync window visibility', () => { vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) }) mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue( - MIRROR_KEY.replace('runtime-b\u00012', 'runtime-b\u00013') + MIRROR_KEY.replace('runtime-b\u00010', 'runtime-b\u00013') ) hook.rerender() await act(settle) @@ -795,7 +797,7 @@ describe('useWebSessionTabsSync window visibility', () => { expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined() mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue( - MIRROR_KEY.replace('runtime-b\u00012', 'runtime-b\u00013') + MIRROR_KEY.replace('runtime-b\u00010', 'runtime-b\u00013') ) hook.rerender() await act(settle) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 994b3638476..073263be99a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -135,11 +135,16 @@ export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 type SessionTabsStreamEvent = | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) - | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } + | { + type: 'snapshots' + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: boolean + } | { type: 'end' } type SessionTabsListAllResult = { snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: boolean } type SnapshotFreshness = { @@ -181,6 +186,7 @@ const latestSessionTabsRemovalFenceByWorktree = new Map() const trackedSessionTabsWorktreeIdsByEnvironment = new Map>() const sessionTabsEnvironmentsByWorktree = new Map>() +const sessionTabsTrackingGenerationByEnvironment = new Map() const lastHostTerminalTabCountByWorktree = new Map() const hostSessionTabIdByLocalKey = new Map() const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< @@ -811,6 +817,10 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) return } const keyPrefix = `${trimmedEnvironmentId}:` + sessionTabsTrackingGenerationByEnvironment.set( + trimmedEnvironmentId, + (sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1 + ) for (const key of latestSessionTabsSnapshotByWorktree.keys()) { if (key.startsWith(keyPrefix)) { latestSessionTabsSnapshotByWorktree.delete(key) @@ -862,6 +872,10 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) clearAllWebRuntimeWakeTerminalRespawn() } +export function getWebSessionTabsTrackingGeneration(environmentId: string): number { + return sessionTabsTrackingGenerationByEnvironment.get(environmentId.trim()) ?? 0 +} + function hostSessionTabMappingKey(args: { environmentId: string worktreeId: string @@ -3976,6 +3990,9 @@ export type HostSessionMirrorPatchFrame = { environmentId: string worktreeId: string decision: WebSessionTabsSnapshotDecision + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number } /** @@ -3990,7 +4007,14 @@ export type HostSessionMirrorPatchFrame = { */ export type HostSessionMirrorPatchVerdict = { frames: readonly HostSessionMirrorPatchFrame[] - fullInventory?: { environmentId: string; publishedSnapshotCount: number } + fullInventory?: { + environmentId: string + publishedSnapshotCount: number + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } } /** @@ -4005,16 +4029,52 @@ export type HostSessionMirrorPatchVerdict = { * `none` settles; `live` and `unverifiable` leave waiters for the next * inventory or per-worktree frame. */ -function settleEmptyHostInventoryOnlyIfHostHasNoTerminals(environmentId: string): void { - const probedGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) - void probeHostLiveTerminals(environmentId, undefined, probedGeneration).then((verdict) => { +type HostSessionMirrorSettleFence = { + environmentId: string + connectionGeneration: number + pairingRevision?: number + trackingGeneration: number +} + +function captureHostSessionMirrorSettleFence( + environmentId: string, + expected: { + connectionGeneration?: number + pairingRevision?: number + trackingGeneration?: number + } = {} +): HostSessionMirrorSettleFence { + return { + environmentId, + connectionGeneration: + expected.connectionGeneration ?? getRuntimeEnvironmentConnectionGeneration(environmentId), + pairingRevision: expected.pairingRevision ?? getRuntimeEnvironmentRevision(environmentId), + trackingGeneration: + expected.trackingGeneration ?? getWebSessionTabsTrackingGeneration(environmentId) + } +} + +function hostSessionMirrorSettleFenceIsCurrent(fence: HostSessionMirrorSettleFence): boolean { + return ( + getRuntimeEnvironmentConnectionGeneration(fence.environmentId) === fence.connectionGeneration && + getRuntimeEnvironmentRevision(fence.environmentId) === fence.pairingRevision && + getWebSessionTabsTrackingGeneration(fence.environmentId) === fence.trackingGeneration + ) +} + +function settleEmptyHostInventoryOnlyIfHostHasNoTerminals( + fence: HostSessionMirrorSettleFence +): void { + void probeHostLiveTerminals( + fence.environmentId, + undefined, + fence.connectionGeneration, + fence.pairingRevision + ).then((verdict) => { // Why: the probe is a round trip, and a reconnect in between would make its // answer speak for a connection whose PTYs nobody listed. - if ( - verdict === 'none' && - getRuntimeEnvironmentConnectionGeneration(environmentId) === probedGeneration - ) { - markHostSessionMirrorHydrated(environmentId) + if (verdict === 'none' && hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorHydrated(fence.environmentId) } }) } @@ -4022,19 +4082,51 @@ function settleEmptyHostInventoryOnlyIfHostHasNoTerminals(environmentId: string) function createHostSessionMirrorSettle( verdict: HostSessionMirrorPatchVerdict ): HostSessionMirrorSettle { + const fenceByEnvironment = new Map() + for (const frame of verdict.frames) { + fenceByEnvironment.set( + frame.environmentId, + captureHostSessionMirrorSettleFence(frame.environmentId, { + connectionGeneration: frame.expectedEnvironmentConnectionGeneration, + pairingRevision: frame.expectedEnvironmentPairingRevision, + trackingGeneration: frame.expectedTrackingGeneration + }) + ) + } + if (verdict.fullInventory) { + fenceByEnvironment.set( + verdict.fullInventory.environmentId, + captureHostSessionMirrorSettleFence(verdict.fullInventory.environmentId, { + connectionGeneration: verdict.fullInventory.expectedEnvironmentConnectionGeneration, + pairingRevision: verdict.fullInventory.expectedEnvironmentPairingRevision, + trackingGeneration: verdict.fullInventory.expectedTrackingGeneration + }) + ) + } return () => { const { frames, fullInventory } = verdict const settles = frames.filter(({ decision }) => decision.settlesHostMirror) if (fullInventory && settles.length === fullInventory.publishedSnapshotCount) { + const fence = fenceByEnvironment.get(fullInventory.environmentId) + if (!fence || !hostSessionMirrorSettleFenceIsCurrent(fence)) { + return + } if (fullInventory.publishedSnapshotCount === 0) { - settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fullInventory.environmentId) + if (fullInventory.authoritative) { + markHostSessionMirrorHydrated(fullInventory.environmentId) + } else { + settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fence) + } return } markHostSessionMirrorHydrated(fullInventory.environmentId) return } for (const { environmentId, worktreeId } of settles) { - markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + const fence = fenceByEnvironment.get(environmentId) + if (fence && hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + } } } } @@ -4048,11 +4140,22 @@ function createHostSessionMirrorSettle( function hostSessionMirrorSettleForPatchlessFrame( decision: WebSessionTabsSnapshotDecision, environmentId: string, - worktreeId: string + worktreeId: string, + expected: { + connectionGeneration?: number + pairingRevision?: number + trackingGeneration?: number + } = {} ): HostSessionMirrorSettle | null { - return decision.settlesHostMirror - ? () => markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) - : null + if (!decision.settlesHostMirror) { + return null + } + const fence = captureHostSessionMirrorSettleFence(environmentId, expected) + return () => { + if (hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + } + } } export function applyWebSessionTabsStorePatch( @@ -4217,7 +4320,9 @@ export function applyWebSessionTabsStorePatch( function loadInitialWebSessionTabs( environmentId: string, + expectedEnvironmentConnectionGeneration: number, expectedEnvironmentPairingRevision: number | undefined, + expectedTrackingGeneration: number, isCurrent: () => boolean ): void { // Why: only a conclusion that reached the store may settle the mirror, so @@ -4294,10 +4399,17 @@ function loadInitialWebSessionTabs( frames: applicable.map((snapshot, position) => ({ environmentId, worktreeId: snapshot.worktree, - decision: decisions[position]! + decision: decisions[position]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration })), fullInventory: { environmentId, + authoritative: result.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, // Why: a workspace the mirror never writes is not part of the // inventory the environment-wide verdict has to account for. publishedSnapshotCount: result.snapshots.filter((snapshot) => @@ -4406,9 +4518,11 @@ export function useWebSessionTabsSync(): void { ? runtimeSessionMirrorEnvironmentKey .split('\u0000') .map((entry) => { - const [environmentId = '', , , rawRevision = ''] = entry.split('\u0001') + const [environmentId = '', , rawConnectionGeneration = '0', rawRevision = ''] = + entry.split('\u0001') return { environmentId, + expectedEnvironmentConnectionGeneration: Number(rawConnectionGeneration), expectedEnvironmentPairingRevision: rawRevision === '' ? undefined : Number(rawRevision) } @@ -4453,6 +4567,9 @@ export function useWebSessionTabsSync(): void { inventoryReceived: boolean latestInventoryReceivedFrame: number pendingMissingWorktrees: Set + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration: number } type VisibilityResumeMissing = { environmentId: string @@ -4630,7 +4747,13 @@ export function useWebSessionTabsSync(): void { frames: decided.map(({ environmentId, snapshot, decision }) => ({ environmentId, worktreeId: snapshot.worktree, - decision + decision, + expectedEnvironmentConnectionGeneration: + batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision: + batch.environments.get(environmentId)?.expectedEnvironmentPairingRevision, + expectedTrackingGeneration: + batch.environments.get(environmentId)?.expectedTrackingGeneration })) }, operations.map(({ snapshot }) => snapshot) @@ -4800,7 +4923,15 @@ export function useWebSessionTabsSync(): void { trackedWorktrees, inventoryReceived: false, latestInventoryReceivedFrame: 0, - pendingMissingWorktrees: new Set() + pendingMissingWorktrees: new Set(), + expectedEnvironmentConnectionGeneration: + environments.find((environment) => environment.environmentId === environmentId) + ?.expectedEnvironmentConnectionGeneration ?? + getRuntimeEnvironmentConnectionGeneration(environmentId), + expectedEnvironmentPairingRevision: environments.find( + (environment) => environment.environmentId === environmentId + )?.expectedEnvironmentPairingRevision, + expectedTrackingGeneration: getWebSessionTabsTrackingGeneration(environmentId) }) } } @@ -4819,7 +4950,11 @@ export function useWebSessionTabsSync(): void { } // Why: the stream's initial snapshot can land after first render, so a one-shot fetch makes initial parity deterministic. - for (const { environmentId, expectedEnvironmentPairingRevision } of environments) { + for (const { + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision + } of environments) { if ( !shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: environmentId, @@ -4829,6 +4964,7 @@ export function useWebSessionTabsSync(): void { continue } let requestedInitialLoad = false + const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) environmentIdBySubscriptionSpec.push(environmentId) subscriptionSpecs.push({ subscribe: (isCurrent, { visibilityGeneration }) => { @@ -4836,7 +4972,13 @@ export function useWebSessionTabsSync(): void { let awaitingVisibilityResumeInventory = isVisibilityRestart if (!requestedInitialLoad) { requestedInitialLoad = true - loadInitialWebSessionTabs(environmentId, expectedEnvironmentPairingRevision, isCurrent) + loadInitialWebSessionTabs( + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + isCurrent + ) } return window.api.runtimeEnvironments.subscribe( { @@ -4957,10 +5099,17 @@ export function useWebSessionTabsSync(): void { frames: applicable.map(({ snapshot }, position) => ({ environmentId, worktreeId: snapshot.worktree, - decision: decisions[position]! + decision: decisions[position]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration })), fullInventory: { environmentId, + authoritative: event.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, // Why: a workspace the mirror never writes is not // part of the inventory this verdict accounts for. publishedSnapshotCount: event.snapshots.filter((snapshot) => @@ -5049,7 +5198,16 @@ export function useWebSessionTabsSync(): void { settleHydration = applyWebSessionTabsStorePatch( (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), { - frames: [{ environmentId, worktreeId: recovered.worktree, decision }] + frames: [ + { + environmentId, + worktreeId: recovered.worktree, + decision, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] }, recovered, event.type === 'updated' && !replayed @@ -5059,7 +5217,12 @@ export function useWebSessionTabsSync(): void { settleHydration = hostSessionMirrorSettleForPatchlessFrame( decision, environmentId, - recovered.worktree + recovered.worktree, + { + connectionGeneration: expectedEnvironmentConnectionGeneration, + pairingRevision: expectedEnvironmentPairingRevision, + trackingGeneration: expectedTrackingGeneration + } ) } } @@ -5137,6 +5300,7 @@ export function useWebSessionTabsSync(): void { ) { return } + const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) let requestedInitialTerminal = false let requestedRespawnAfterWake = false @@ -5146,7 +5310,8 @@ export function useWebSessionTabsSync(): void { event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }, response: RuntimeRpcResponse, isCurrent: () => boolean, - receivedFrame: number + receivedFrame: number, + expectedTrackingGeneration: number ): Promise => { const recovered = await recoverWebSessionTerminalOrphansBeforeApply( useAppStore.getState(), @@ -5193,13 +5358,29 @@ export function useWebSessionTabsSync(): void { // Why: a rejected frame settles only on the accepted view that outranked it. let settleMirror: HostSessionMirrorSettle | null = fresh ? null - : hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree) + : hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree, { + connectionGeneration: activeWorktreeRuntimeConnectionGeneration, + pairingRevision: expectedEnvironmentPairingRevision, + trackingGeneration: expectedTrackingGeneration + }) try { if (fresh) { const replayed = isRuntimeSubscriptionReplayResponse(response) settleMirror = applyWebSessionTabsStorePatch( (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), - { frames: [{ environmentId, worktreeId: recovered.worktree, decision }] }, + { + frames: [ + { + environmentId, + worktreeId: recovered.worktree, + decision, + expectedEnvironmentConnectionGeneration: + activeWorktreeRuntimeConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] + }, recovered, event.type === 'updated' && !replayed ) @@ -5278,7 +5459,13 @@ export function useWebSessionTabsSync(): void { event.worktree, receivedFrame ) - void applyActiveSnapshot(event, response, isCurrent, receivedFrame) + void applyActiveSnapshot( + event, + response, + isCurrent, + receivedFrame, + expectedTrackingGeneration + ) .catch((error) => { if (isCurrent()) { console.warn( diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 0e2d300bef3..a3f860f1ea1 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -84,6 +84,13 @@ export function getRuntimeEnvironmentConnectionGeneration(environmentId: string) export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void => connectionGenerationByEnvironment.clear() +export const setRuntimeEnvironmentConnectionGenerationForTests = ( + environmentId: string, + generation: number +): void => { + connectionGenerationByEnvironment.set(environmentId, generation) +} + function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number { const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1 connectionGenerationByEnvironment.set(environmentId, next) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 0c4e45b5bcd..bfd58f181e1 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -106,6 +106,8 @@ export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentia export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const +export const SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY = + 'session-tabs.authoritative-inventory.v1' as const export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = 'agent-session.session-boundary.v1' as const export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' @@ -189,6 +191,7 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 5efaf0aed9d..b705e279b33 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -2,6 +2,7 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability @@ -15,6 +16,7 @@ export function remoteRuntimeClientCapabilities( return Array.from( new Set([ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, diff --git a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts index 1fbe42bddb2..9855d8d92b0 100644 --- a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts +++ b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts @@ -408,6 +408,15 @@ test('foregrounds a preserved daemon PTY after the paired host relaunches', asyn .toBe(true) await expectTerminalInteractive(client, target, 'x') target.handle = await findTerminalHandle(client, worktreeId, target.parentTabId) + const restored = await callRuntime<{ terminal: { ptyId: string | null } }>( + client.page, + client.environmentId, + 'terminal.show', + { terminal: target.handle } + ) + expect(restored.terminal.ptyId, 'host inventory must preserve the daemon PTY identity').toBe( + target.ptyId + ) const reconnectControl = await createHostTerminal(client, worktreeId, 'reconnect-control') terminals.push(reconnectControl) diff --git a/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts b/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts new file mode 100644 index 00000000000..c630d0420fc --- /dev/null +++ b/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from '../../src/main/daemon/session-subprocess-handle' +import { TerminalHost } from '../../src/main/daemon/terminal-host' + +vi.mock('../../src/renderer/src/store', () => ({ + useAppStore: { + setState: vi.fn(), + getState: vi.fn(() => ({})), + subscribe: vi.fn(() => () => {}) + } +})) + +import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '../../src/renderer/src/store/slices/runtime-status' +import { + hasHostSessionMirrorHydrated, + parkUntilHostSessionMirrorHydrates, + resetHostSessionMirrorHydrationForTests +} from '../../src/renderer/src/runtime/host-session-mirror-hydration' +import { clearHostLiveTerminalProbesForTests } from '../../src/renderer/src/runtime/host-live-terminal-probe' +import { replaceRuntimeEnvironmentRevisions } from '../../src/renderer/src/runtime/runtime-environment-revision' +import { applyWebSessionTabsStorePatch } from '../../src/renderer/src/runtime/web-session-tabs-sync' + +const ENVIRONMENT_ID = 'env-live-unpublished' +const WORKTREE = 'repo1::/path/wt1' + +type WriterSubprocess = SubprocessHandle & { + write: ReturnType void>> + exit: () => void +} + +function createWriterSubprocess(pid: number): WriterSubprocess { + let onExit: ((code: number) => void) | null = null + const write = vi.fn<(data: string) => void>() + return { + pid, + getForegroundProcess: () => 'codex', + write, + resize: vi.fn(), + kill: vi.fn(), + terminateOwnedTree: () => 'unavailable', + forceKill: vi.fn(), + signal: vi.fn(), + onData: vi.fn(), + onExit: (listener) => { + onExit = listener + }, + dispose: vi.fn(), + exit: () => onExit?.(0) + } +} + +describe('unpublished empty inventory daemon oracle', () => { + beforeEach(() => { + resetHostSessionMirrorHydrationForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() + clearHostLiveTerminalProbesForTests() + replaceRuntimeEnvironmentRevisions([]) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('keeps the resume dispatch parked and exactly one daemon writer live', async () => { + const subprocesses: ReturnType[] = [] + const host = new TerminalHost({ + spawnSubprocess: () => { + const subprocess = createWriterSubprocess(90_000 + subprocesses.length) + subprocesses.push(subprocess) + return subprocess + } + }) + await host.createOrAttach({ + sessionId: 'original-live-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const call = vi.fn(async () => ({ + ok: true, + result: { + terminals: host.listSessions().map(({ sessionId }) => ({ + handle: sessionId, + worktreeId: WORKTREE, + connected: true + })), + totalCount: host.listSessions().length, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + } + })) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + let replacement: Promise | null = null + parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, WORKTREE, () => { + resumeSweeps += 1 + replacement = host.createOrAttach({ + sessionId: 'replacement-resume-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + }) + + applyWebSessionTabsStorePatch(() => ({}), { + frames: [], + fullInventory: { environmentId: ENVIRONMENT_ID, publishedSnapshotCount: 0 } + })() + await new Promise((resolve) => setTimeout(resolve, 0)) + await (replacement ?? Promise.resolve()) + + const liveSessionIds = host.listSessions().map(({ sessionId }) => sessionId) + for (const sessionId of liveSessionIds) { + host.write(sessionId, `writer:${sessionId}`) + } + + expect({ + resumeSweeps, + mirrorHydrated: hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE), + liveSessionIds, + subprocessPids: subprocesses.map(({ pid }) => pid), + writerCalls: subprocesses.map(({ write }) => write.mock.calls) + }).toEqual({ + resumeSweeps: 0, + mirrorHydrated: false, + liveSessionIds: ['original-live-session'], + subprocessPids: [90_000], + writerCalls: [[['writer:original-live-session']]] + }) + expect(call).toHaveBeenCalledOnce() + for (const subprocess of subprocesses) { + subprocess.exit() + } + await host.dispose() + }) + + it('settles an authoritative empty inventory and releases the dispatch without a probe', async () => { + const call = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, WORKTREE, () => { + resumeSweeps += 1 + }) + + applyWebSessionTabsStorePatch(() => ({}), { + frames: [], + fullInventory: { + environmentId: ENVIRONMENT_ID, + publishedSnapshotCount: 0, + authoritative: true + } + })() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect({ + resumeSweeps, + mirrorHydrated: hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE), + probeCalls: call.mock.calls.length + }).toEqual({ resumeSweeps: 1, mirrorHydrated: true, probeCalls: 0 }) + }) + + it('records legacy unconditional empty hydration releasing the resume dispatch', async () => { + const subprocesses: ReturnType[] = [] + const host = new TerminalHost({ + spawnSubprocess: () => { + const subprocess = createWriterSubprocess(90_000 + subprocesses.length) + subprocesses.push(subprocess) + return subprocess + } + }) + await host.createOrAttach({ + sessionId: 'original-live-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + // Legacy hosts answer the liveness probe with a scoped zero-terminal census. + const call = vi.fn(async () => ({ + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + } + })) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + let replacement: Promise | null = null + parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, WORKTREE, () => { + resumeSweeps += 1 + replacement = host.createOrAttach({ + sessionId: 'replacement-resume-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + }) + + applyWebSessionTabsStorePatch(() => ({}), { + frames: [], + fullInventory: { environmentId: ENVIRONMENT_ID, publishedSnapshotCount: 0 } + })() + await new Promise((resolve) => setTimeout(resolve, 0)) + await (replacement ?? Promise.resolve()) + + const liveSessionIds = host.listSessions().map(({ sessionId }) => sessionId) + for (const sessionId of liveSessionIds) { + host.write(sessionId, `writer:${sessionId}`) + } + + expect({ + resumeSweeps, + mirrorHydrated: hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE), + liveSessionIds, + subprocessPids: subprocesses.map(({ pid }) => pid), + writerCalls: subprocesses.map(({ write }) => write.mock.calls) + }).toEqual({ + resumeSweeps: 1, + mirrorHydrated: true, + liveSessionIds: ['original-live-session', 'replacement-resume-session'], + subprocessPids: [90_000, 90_001], + writerCalls: [[['writer:original-live-session']], [['writer:replacement-resume-session']]] + }) + expect(call).toHaveBeenCalledOnce() + for (const subprocess of subprocesses) { + subprocess.exit() + } + await host.dispose() + }) +}) From 3d8b9eeeb02e4cc7366c198ec7acadfaa8d452b2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:31:39 -0700 Subject: [PATCH 008/118] fix(crash-reporting): stop discarding the whole module list on POSIX dumps (#16687) --- .../minidump-crash-signature.test.ts | 47 +++++++++++++++++++ .../minidump-crash-signature.ts | 7 ++- .../crash-reporting/minidump-stream-reader.ts | 2 +- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/main/crash-reporting/minidump-crash-signature.test.ts b/src/main/crash-reporting/minidump-crash-signature.test.ts index 2d1fbb90972..8e878cf3d23 100644 --- a/src/main/crash-reporting/minidump-crash-signature.test.ts +++ b/src/main/crash-reporting/minidump-crash-signature.test.ts @@ -194,6 +194,22 @@ function buildDump(options: { return { dump: builder.build(streams) } } +/** Offset of a stream's directory entry: `{type, size, rva}`. */ +function streamEntry(dump: Buffer, type: number): number { + const directoryRva = dump.readUInt32LE(12) + for (let index = 0; index < dump.readUInt32LE(8); index += 1) { + const entry = directoryRva + index * 12 + if (dump.readUInt32LE(entry) === type) { + return entry + } + } + throw new Error(`no stream of type ${type}`) +} + +function moduleListRva(dump: Buffer): number { + return dump.readUInt32LE(streamEntry(dump, STREAM_TYPE_MODULE_LIST) + 8) +} + const FATAL_LINE = '[8104:1234:0815/143022.123456:FATAL:render_frame_impl.cc(4821)] Check failed: !is_detached_.' @@ -340,6 +356,37 @@ describe('parseMinidumpCrashSignature', () => { expect(signature?.faultingModuleOffset).toBe('0x1234') }) + it('resolves a faulting module past index 1024 on a real macOS image count', () => { + // A measured macOS renderer carries 1042 loaded images; a cap below that + // dropped the whole module list, so no macOS report could name a module. + const modules = Array.from({ length: 1042 }, (_, index) => ({ + base: 0x1_0000_0000n + BigInt(index) * 0x1_0000n, + size: 0x1000, + name: `/Applications/Orca.app/Contents/Frameworks/lib${index}.dylib` + })) + const { dump } = buildDump({ + exception: { code: 11, address: 0x1_0000_0000n + 1030n * 0x1_0000n + 0x24n }, + modules + }) + + const signature = parseMinidumpCrashSignature(dump) + + expect(signature?.faultingModule).toBe('lib1030.dylib') + expect(signature?.faultingModuleOffset).toBe('0x24') + }) + + it('still drops the module list when the claimed module count is absurd', () => { + const { dump } = buildDump({ + exception: { code: 11, address: 0x7ff7_0000_0010n }, + modules: [{ base: 0x7ff7_0000_0000n, size: 0x1000, name: '/opt/orca/orca' }] + }) + const corrupt = Buffer.from(dump) + corrupt.writeUInt32LE(0xffff_ffff, moduleListRva(corrupt)) + + expect(() => parseMinidumpCrashSignature(corrupt)).not.toThrow() + expect(parseMinidumpCrashSignature(corrupt)?.faultingModule).toBeUndefined() + }) + it('omits the faulting module when no image range covers the address', () => { const { dump } = buildDump({ exception: { code: 11, address: 0x10n }, diff --git a/src/main/crash-reporting/minidump-crash-signature.ts b/src/main/crash-reporting/minidump-crash-signature.ts index 3e246d4b77a..fe4e930504d 100644 --- a/src/main/crash-reporting/minidump-crash-signature.ts +++ b/src/main/crash-reporting/minidump-crash-signature.ts @@ -11,13 +11,16 @@ // rather than throwing: a truncated dump must degrade, not break crash // reporting. -import { findStream, isMinidump, MAX_MODULES, MinidumpView } from './minidump-stream-reader' +import { findStream, isMinidump, MinidumpView } from './minidump-stream-reader' import { readCrashpadAnnotations } from './minidump-crashpad-annotations' const STREAM_TYPE_MODULE_LIST = 4 const STREAM_TYPE_EXCEPTION = 6 const MODULE_RECORD_SIZE = 108 +// 8x headroom over a measured 1042-image macOS renderer, whose whole list a +// 1_024 cap dropped; a dump claiming more than this is corrupt. +const MAX_MODULE_LIST_MODULES = 8_192 const MODULE_BASE_OFFSET = 0 const MODULE_SIZE_OFFSET = 8 const MODULE_NAME_RVA_OFFSET = 20 @@ -70,7 +73,7 @@ function readModules(view: MinidumpView): ModuleRecord[] { return [] } const count = view.u32(stream.rva) - if (count === null || count > MAX_MODULES) { + if (count === null || count > MAX_MODULE_LIST_MODULES) { return [] } const modules: ModuleRecord[] = [] diff --git a/src/main/crash-reporting/minidump-stream-reader.ts b/src/main/crash-reporting/minidump-stream-reader.ts index 1230a6f9c48..4b9f32a07a3 100644 --- a/src/main/crash-reporting/minidump-stream-reader.ts +++ b/src/main/crash-reporting/minidump-stream-reader.ts @@ -13,7 +13,7 @@ const DIRECTORY_ENTRY_SIZE = 12 // A dump claiming an absurd stream count is corrupt; cap before iterating. const MAX_STREAMS = 4_096 export const MAX_ANNOTATION_VALUE_BYTES = 8_192 -// Shared cap: both the MINIDUMP_MODULE_LIST and Crashpad's per-module info list. +// Cap for Crashpad's per-module info list; the MINIDUMP_MODULE_LIST is capped separately. export const MAX_MODULES = 1_024 export type LocationDescriptor = { From 762fb05caa6e252e34768ca80d9d41946c41e1cf Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:31:57 -0700 Subject: [PATCH 009/118] fix(agents): one-shot submit-retry Enter for codex (STA-5379) (#16689) Codex silently discards Enter for ~75-150ms after its composer glyph first renders, and the boundary widens with prompt size and machine load, so no fixed first-Enter delay is provably safe on slow hosts. Submit success is not verifiable from PTY output, but a redundant Enter is a measured no-op on codex in both post-submit states, so send one blind retry after the first Enter. - tui-agent-config: new optional submitRetryDelayMs knob, set to 1200 on codex only; every other agent is byte-identical to today. - agent-paste-draft: after the post-paste '\r', wait the configured gap and send exactly one more '\r' inside the same PTY input transaction, so a concurrent paste cannot interleave. The retry is best-effort and never downgrades the first Enter's result. - Retry tests live in a new file to keep agent-paste-draft.test.ts under the max-lines budget. active-agent-note-send is deliberately exempt: its Enter rides the terminal.send RPC (different transport, server-side sendable guard), has no local agent identity to read the config from, and only fires on an already-running agent, where the codex cold-boot submit gate cannot occur. --- .../agent-paste-draft-submit-retry.test.ts | 197 ++++++++++++++++++ .../src/lib/agent-paste-draft.test.ts | 4 +- src/renderer/src/lib/agent-paste-draft.ts | 29 ++- src/shared/tui-agent-config.ts | 5 +- 4 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts diff --git a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts new file mode 100644 index 00000000000..5b763aa5c6d --- /dev/null +++ b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES, + pasteDraftWhenAgentReady, + POST_PASTE_SUBMIT_DELAY_MS, + sendAgentDraftPasteContent +} from './agent-paste-draft' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' + +const testState = vi.hoisted(() => ({ + appState: { + settings: {} as Record, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record, + runtimePaneTitlesByTabId: {}, + tabsByWorktree: {} as Record, + repos: [] as { id: string; connectionId: string | null; executionHostId?: string | null }[], + worktreesByRepo: {} as Record + }, + ptyObserver: null as ((data: string) => void) | null, + unsubscribe: vi.fn(), + subscribeToPtyData: vi.fn(), + replayPreHandlerPtyData: vi.fn(), + isRemoteRuntimePtyId: vi.fn(), + sendRuntimePtyInputVerified: vi.fn(), + inspectRuntimeTerminalProcess: vi.fn(), + subscribeToRuntimeTerminalData: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => testState.appState, + subscribe: () => () => {} + } +})) + +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: testState.subscribeToPtyData +})) + +vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ + replayPreHandlerPtyData: testState.replayPreHandlerPtyData +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, + sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified, + inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess +})) + +vi.mock('@/runtime/runtime-terminal-stream', () => ({ + subscribeToRuntimeTerminalData: testState.subscribeToRuntimeTerminalData +})) + +const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything' +const RENDER_QUIET_MS = 1500 +const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123' +const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~` +const CODEX_SUBMIT_RETRY_DELAY_MS = TUI_AGENT_CONFIG.codex.submitRetryDelayMs ?? 0 + +describe('post-paste submit retry Enter', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout + }) + testState.appState.settings = {} + testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] } + testState.appState.tabsByWorktree = {} + testState.appState.repos = [] + testState.appState.worktreesByRepo = {} + testState.ptyObserver = null + testState.unsubscribe.mockReset() + testState.subscribeToPtyData.mockReset() + testState.subscribeToPtyData.mockImplementation( + (_ptyId: string, observer: (data: string) => void) => { + testState.ptyObserver = observer + return testState.unsubscribe + } + ) + testState.replayPreHandlerPtyData.mockReset() + testState.isRemoteRuntimePtyId.mockReset() + testState.isRemoteRuntimePtyId.mockReturnValue(false) + testState.sendRuntimePtyInputVerified.mockReset() + testState.sendRuntimePtyInputVerified.mockResolvedValue(true) + testState.inspectRuntimeTerminalProcess.mockReset() + testState.subscribeToRuntimeTerminalData.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('sends one retry Enter after the configured gap for agents that can eat the first Enter', async () => { + const promise = startCodexSubmit() + await signalCodexComposerReady() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + + expect(enterWrites()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(CODEX_SUBMIT_RETRY_DELAY_MS - 1) + expect(enterWrites()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(2) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r') + expect(vi.getTimerCount()).toBe(0) + }) + + it('sends exactly one Enter for agents without a submit retry delay', async () => { + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'gemini', + submit: true + }) + await flushMicrotasks() + testState.ptyObserver?.(DECSET_BRACKETED_PASTE) + await vi.advanceTimersByTimeAsync(RENDER_QUIET_MS) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS) + + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('holds the PTY input transaction across the retry Enter', async () => { + const writes: string[] = [] + testState.sendRuntimePtyInputVerified.mockImplementation( + async (_settings: unknown, _ptyId: string, data: string) => { + writes.push(data) + return true + } + ) + + const promise = startCodexSubmit() + await signalCodexComposerReady() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + // Competing paste on the same PTY: it must not open a frame the retry can land in. + const competing = sendAgentDraftPasteContent( + {}, + 'pty-1', + 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1) + ) + await flushMicrotasks(10) + expect(writes).toEqual([PASTED_ISSUE_URL, '\r']) + + await vi.advanceTimersByTimeAsync(CODEX_SUBMIT_RETRY_DELAY_MS) + await expect(promise).resolves.toBe(true) + await expect(competing).resolves.toBe(true) + + expect(writes.slice(0, 3)).toEqual([PASTED_ISSUE_URL, '\r', '\r']) + expect(writes.at(3)).toBe('\x1b[200~') + }) + + it('keeps a successful submit successful when the retry Enter is rejected', async () => { + testState.sendRuntimePtyInputVerified + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('terminal_not_writable')) + + const promise = startCodexSubmit() + await signalCodexComposerReady() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS) + + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(2) + }) +}) + +function enterWrites(): unknown[][] { + return testState.sendRuntimePtyInputVerified.mock.calls.filter((call) => call[2] === '\r') +} + +function startCodexSubmit(): Promise { + return pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'codex', + submit: true + }) +} + +async function signalCodexComposerReady(): Promise { + await flushMicrotasks() + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + await flushMicrotasks() +} + +async function flushMicrotasks(iterations = 2): Promise { + for (let index = 0; index < iterations; index += 1) { + await Promise.resolve() + } +} diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index a09c88901c4..c8a660f2576 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -13,6 +13,7 @@ import { sendBracketedPasteToRunningAgent, submitPromptToAgentPty } from './agent-paste-draft' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' const testState = vi.hoisted(() => ({ appState: { @@ -70,6 +71,7 @@ const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything const CODEX_DYNAMIC_COMPOSER_PROMPT_RENDER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}' const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123' const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~` +const CODEX_SUBMIT_RETRY_DELAY_MS = TUI_AGENT_CONFIG.codex.submitRetryDelayMs ?? 0 describe('pasteDraftWhenAgentReady', () => { beforeEach(() => { @@ -169,7 +171,7 @@ describe('pasteDraftWhenAgentReady', () => { 'pty-1', PASTED_ISSUE_URL ) - await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r') expect(testState.unsubscribe).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index f078383333d..72e467ee59e 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -129,7 +129,8 @@ export async function pasteDraftWhenAgentReady(args: { settings, ptyId, content, - submit: submit === true + submit: submit === true, + agent }) } @@ -168,7 +169,8 @@ export async function pasteDraftToAgentPtyWhenReady(args: { settings, ptyId, content, - submit: submit === true + submit: submit === true, + agent }) } @@ -197,11 +199,13 @@ async function sendBracketedPasteToAgent(args: { ptyId: string content: string submit: boolean + agent?: TuiAgent }): Promise { - const { settings = useAppStore.getState().settings, ptyId, content, submit } = args + const { settings = useAppStore.getState().settings, ptyId, content, submit, agent } = args + const submitRetryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined try { - // Why: paste + Enter must be one transaction, or a concurrent paste on this PTY - // can slip between them and submit a half-written prompt. + // Why: paste + Enter (+ retry Enter) must be one transaction, or a concurrent + // paste on this PTY can slip between them and submit a half-written prompt. return await runTerminalPtyInputTransaction(ptyId, async () => { const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content) if (!pasted || !submit) { @@ -212,7 +216,20 @@ async function sendBracketedPasteToAgent(args: { // Enter arrive in the same PTY write. Split the submit into the next turn so // the TUI processes bracketed-paste termination before handling Enter. await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - return await sendRuntimePtyInputVerified(settings, ptyId, '\r') + const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r') + + if (submitRetryDelayMs !== undefined) { + // Why: agents that render their composer before Enter is live silently eat + // the first Enter; the retry is best-effort and never downgrades `submitted`. + await new Promise((resolve) => window.setTimeout(resolve, submitRetryDelayMs)) + try { + await sendRuntimePtyInputVerified(settings, ptyId, '\r') + } catch { + // Why: a rejected retry leaves the first Enter's verdict untouched. + } + } + + return submitted }) } catch { return false diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index de3c79a68b1..4a89e1cb13b 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -42,6 +42,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number + /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ + submitRetryDelayMs?: number /** Windows Shift+Enter encoding override; omitted agents keep the legacy Esc+CR path. */ windowsShiftEnterEncoding?: 'csi-u' /** Paste newlines for TUIs that read Windows console input records instead of VT paste frames. */ @@ -90,7 +92,8 @@ export const TUI_AGENT_CONFIG: Record = { windowsInputRecordPasteNewline: 'alt-enter', preflightTrust: 'codex', draftPasteReadySignal: 'codex-composer-prompt', - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + submitRetryDelayMs: 1200 }, autohand: { detectCmd: 'autohand', From 6e28c9a9b7183844ebe1dd4872040a0c0d305b18 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:33:01 -0700 Subject: [PATCH 010/118] fix(editor): constrain image previews before load instead of laying out at natural size (#16686) --- .../components/editor/ImageViewer.test.tsx | 23 +++++++++++++++++++ .../src/components/editor/ImageViewer.tsx | 4 +++- .../components/editor/ImageViewerPopup.tsx | 6 ++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/editor/ImageViewer.test.tsx b/src/renderer/src/components/editor/ImageViewer.test.tsx index cdd067ae0e3..8845cf33bd5 100644 --- a/src/renderer/src/components/editor/ImageViewer.test.tsx +++ b/src/renderer/src/components/editor/ImageViewer.test.tsx @@ -239,3 +239,26 @@ describe('ImageViewer preview source retry', () => { expect(findElementsByType(rendered, 'img')).toHaveLength(0) }) }) + +describe('ImageViewer pre-load layout box', () => { + beforeEach(() => { + reactHookRuntime.states = [] + reactHookRuntime.index = 0 + vi.clearAllMocks() + }) + + // Why: the scroll surface's inner box is w-max/h-max, so percentage maxes resolve + // to none and a 6000x4000 image lays out at natural resolution before onLoad. + it('bounds both scroll surfaces with viewport units before the image loads', async () => { + const images = findElementsByType(await renderExpandedImageViewer(pngBase64(1)), 'img') + + expect(images).toHaveLength(2) + for (const image of images) { + const className = String(image.props.className) + expect(className).toContain('max-h-[100vh]') + expect(className).toContain('max-w-[100vw]') + expect(className).not.toContain('max-h-full') + expect(className).not.toContain('max-w-full') + } + }) +}) diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index 81917eed2bb..b3e86bdfc81 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -291,7 +291,9 @@ export default function ImageViewer({ ? 'block h-auto max-h-none max-w-full' : inlineImageLayoutSize ? 'block h-full w-full' - : 'block max-h-full max-w-full' + : // Why: the w-max/h-max scroll box makes percentage maxes resolve to + // none, so only viewport units bound the image before onLoad. + 'block max-h-[100vh] max-w-[100vw]' )} onLoad={(event) => { const img = event.currentTarget diff --git a/src/renderer/src/components/editor/ImageViewerPopup.tsx b/src/renderer/src/components/editor/ImageViewerPopup.tsx index 6eb3ac99d58..4cce2b699a3 100644 --- a/src/renderer/src/components/editor/ImageViewerPopup.tsx +++ b/src/renderer/src/components/editor/ImageViewerPopup.tsx @@ -61,7 +61,11 @@ export default function ImageViewerPopup({ alt={filename} className={cn( 'object-contain', - imageLayoutSize ? 'block h-full w-full' : 'block max-h-full max-w-full' + imageLayoutSize + ? 'block h-full w-full' + : // Why: the w-max/h-max scroll box makes percentage maxes resolve to + // none, so only viewport units bound the image before onLoad. + 'block max-h-[100vh] max-w-[100vw]' )} /> From 9c01e09ecc9d3c1203968ace9945d16edfb35dd2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:42:37 -0700 Subject: [PATCH 011/118] Revert "fix(codex): launch WSL accounts from direct homes" and "refactor(codex): remove WSL runtime mirror machinery" (#16722) This reverts commit ebcd637db981b0ce272c8e183794a8fc7caaa7be (#16504) and dependent commit 673842db35b37557006d9f20d04e077780253d82 (#16505). Launch-blocker rationale (findings-counsel validated): - P0 Data Loss (F06-1): The WSL legacy auth drain deleteSource=1 path deletes intact source auth without re-validating the destination after concurrent destination rewrites, permanently corrupting auth credentials on upgrade. - P1 Workflow Regression (FC-01): Pre-upgrade sessions under ~/.local/share/orca/codex-runtime-home/home are not linked into direct homes, breaking /resume in the Codex CLI for upgrading WSL users. --- .../agent-auth-restart-preservation.test.ts | 126 ++--- src/main/agent-auth-restart-preservation.ts | 49 +- ...sl-runtime-auth-drain-apply-script.test.ts | 170 ------ .../legacy-wsl-runtime-auth-drain.test.ts | 212 ------- .../legacy-wsl-runtime-auth-drain.ts | 319 ----------- .../codex-accounts/runtime-home-service.ts | 529 ++++++++++++------ .../runtime-home-wsl-managed-accounts.test.ts | 239 +++++++- .../runtime-home-wsl-session-bridge.test.ts | 128 ++++- .../runtime-home-wsl-system-default.test.ts | 47 +- .../wsl-codex-auth-batch-reader.ts | 70 --- src/main/codex/codex-pane-account-registry.ts | 9 - .../codex/codex-pane-launch-account.test.ts | 2 +- src/main/codex/codex-pane-launch-account.ts | 17 +- .../codex/codex-stale-pane-accounts.test.ts | 31 - src/main/pty/codex-home-wsl-env.ts | 4 +- .../__fixtures__/wsl-invocation-allowlist.txt | 1 + .../child-process-import-allowlist.txt | 1 + 17 files changed, 788 insertions(+), 1166 deletions(-) delete mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts delete mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts delete mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts delete mode 100644 src/main/codex-accounts/wsl-codex-auth-batch-reader.ts diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index b94bcc2435d..441ec609624 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -15,7 +15,8 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex') - }) + }), + syncActiveWslSelectionsBeforeRestart: vi.fn() }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -32,13 +33,35 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['codex', 'claude', 'flush']) }) - it('runs Claude preservation after Codex and before the store flush', async () => { + 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 () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex-host') + }), + syncActiveWslSelectionsBeforeRestart: vi.fn(() => { + calls.push('codex-wsl') }) }, claudeRuntimeAuth: { @@ -53,80 +76,26 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex-host', 'claude', 'flush']) + expect(calls).toEqual(['codex-host', 'claude', 'codex-wsl', 'flush']) }) - it('drains retained WSL Codex auth before flushing the store', async () => { - const calls: string[] = [] - - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection: vi.fn(() => { - calls.push('codex-host') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { - calls.push('codex-wsl') - }) - }, - store: { - flushPendingOrThrowAsync: vi.fn(async () => { - calls.push('flush') - }) - } - }) - - expect(calls).toEqual(['codex-host', 'codex-wsl', 'flush']) - }) - - it('does not release restart while the bounded WSL drain is still running', async () => { - vi.useFakeTimers() - let finishWslDrain!: () => void - let settled = false - const flushPendingOrThrowAsync = vi.fn() - - const preservation = preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection: vi.fn(), - syncActiveWslSelectionsBeforeRestart: vi.fn( - () => - new Promise((resolve) => { - finishWslDrain = resolve - }) - ) - }, - store: { flushPendingOrThrowAsync } - }).then(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - await vi.advanceTimersByTimeAsync(1) - expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) - expect(settled).toBe(false) - - finishWslDrain() - await preservation - expect(settled).toBe(true) - }) - - it('continues after the bounded WSL drain fails without logging secrets', async () => { + it('continues after WSL Codex preservation fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const flushPendingOrThrowAsync = vi.fn() await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(), - syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { + syncActiveWslSelectionsBeforeRestart: vi.fn(() => { 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') }) @@ -147,7 +116,8 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { throw new Error('codex-token-secret') - }) + }), + syncActiveWslSelectionsBeforeRestart: vi.fn() }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -200,36 +170,6 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) }) - it('gives store persistence its own timeout after auth preservation expires', async () => { - vi.useFakeTimers() - let finishStoreFlush!: () => void - let settled = false - - const preservation = preserveAgentAuthBeforeRestart({ - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(() => new Promise(() => {})) - }, - store: { - flushPendingOrThrowAsync: vi.fn( - () => - new Promise((resolve) => { - finishStoreFlush = resolve - }) - ) - } - }).then(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - await vi.advanceTimersByTimeAsync(1) - expect(settled).toBe(false) - - finishStoreFlush() - await preservation - expect(settled).toBe(true) - }) - it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index e351bfee334..eaae45f331b 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -4,8 +4,10 @@ import type { Store } from './persistence' const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 -type CodexRuntimeAuthSync = Pick & - Partial> +type CodexRuntimeAuthSync = Pick< + CodexRuntimeHomeService, + 'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart' +> type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick @@ -25,26 +27,35 @@ export async function preserveAgentAuthBeforeRestart({ claudeRuntimeAuth, store }: AgentAuthRestartPreservationOptions): Promise { - runCodexPreservationStep(codexRuntimeHome) - // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. - const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) + const startedAt = Date.now() - if (claudeRuntimeAuth) { + runCodexPreservationStep(codexRuntimeHome) + + const remainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) + if (claudeRuntimeAuth && remainingMs > 0) { await runWithinLifecycleTimeout( 'Claude auth preservation', () => claudeRuntimeAuth.syncForCurrentSelection(), - AUTH_PRESERVATION_TIMEOUT_MS + remainingMs ) + } else if (claudeRuntimeAuth) { + logStepTimeout('Claude auth preservation', 0) } - const storePreservation = store - ? runWithinLifecycleTimeout( - 'Store persistence', - () => store.flushPendingOrThrowAsync(), - AUTH_PRESERVATION_TIMEOUT_MS - ) - : Promise.resolve() - await Promise.all([wslCodexPreservation, storePreservation]) + 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 + ) + } } function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null | undefined): void { @@ -55,11 +66,11 @@ function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null } } -async function runWslCodexPreservationStep( +function runWslCodexPreservationStep( codexRuntimeHome: CodexRuntimeAuthSync | null | undefined -): Promise { +): void { try { - await codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart?.() + codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart() } catch (error) { logStepFailure('Codex auth preservation', error) } @@ -78,7 +89,7 @@ async function runWithinLifecycleTimeout( }) // Why: this timeout only releases the restart/update path. It does not - // cancel a sync that already started. + // cancel a sync that already started, and Codex sync is synchronous today. const timeoutResult = new Promise<'timeout'>((resolve) => { timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts deleted file mode 100644 index 44ce6a6e00f..00000000000 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { _internals } from './legacy-wsl-runtime-auth-drain' - -const isWindows = process.platform === 'win32' - -const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' -const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n' -const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' -// Codex truncates before it writes, so a read landing mid-rotation sees this. -const TORN_AUTH = '{"tokens":{"exp' -const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n' -const TORN_CREDENTIALS = '{"server":' - -function sha256(contents: string): string { - return createHash('sha256').update(contents).digest('hex') -} - -/** - * Runs the real guest script under `sh`, with `sha256sum` shimmed so a chosen - * hash call can rewrite the source underneath the script. That is the only way - * to land Codex's in-place rotation inside the window the script itself opens. - */ -function runApplyScript( - options: { - rewriteSourceAfterHashCall?: number - rewriteBytes?: string - rewriteTarget?: 'source-auth' | 'source-credentials' | 'target-auth' - sourceCredentials?: string - } = {} -): { - legacyAuth: string - status: number - targetAuth: string - targetCredentials: string | null -} { - const root = mkdtempSync(join(tmpdir(), 'orca-drain-apply-')) - const legacyHome = join(root, 'legacy') - const targetHome = join(root, 'account') - const binDir = join(root, 'bin') - for (const dir of [legacyHome, targetHome, binDir]) { - mkdirSync(dir, { recursive: true }) - } - const legacyAuthPath = join(legacyHome, 'auth.json') - const targetAuthPath = join(targetHome, 'auth.json') - const legacyCredentialsPath = join(legacyHome, '.credentials.json') - const targetCredentialsPath = join(targetHome, '.credentials.json') - writeFileSync(legacyAuthPath, SOURCE_AUTH) - writeFileSync(targetAuthPath, TARGET_AUTH) - if (options.sourceCredentials !== undefined) { - writeFileSync(legacyCredentialsPath, options.sourceCredentials) - } - - const counterPath = join(root, 'hash-calls') - writeFileSync(counterPath, '0') - const shimPath = join(binDir, 'sha256sum') - writeFileSync( - shimPath, - `#!/usr/bin/env node -const { createHash } = require('node:crypto') -const fs = require('node:fs') -const file = process.argv[process.argv.length - 1] -process.stdout.write( - createHash('sha256').update(fs.readFileSync(file)).digest('hex') + ' ' + file + '\\n' -) -const calls = Number(fs.readFileSync(process.env.HASH_COUNTER, 'utf8')) + 1 -fs.writeFileSync(process.env.HASH_COUNTER, String(calls)) -if (process.env.REWRITE_AFTER && calls === Number(process.env.REWRITE_AFTER)) { - fs.writeFileSync(process.env.REWRITE_TARGET, process.env.REWRITE_BYTES) -} -` - ) - chmodSync(shimPath, 0o755) - - let status = 0 - try { - execFileSync( - '/bin/sh', - [ - '-c', - _internals.applyLegacyAuthScript, - 'sh', - legacyHome, - join(root, 'absent-active-home'), - join(root, 'absent-marker.json'), - targetHome, - sha256(SOURCE_AUTH), - sha256(TARGET_AUTH), - '1', - '0', - options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials) - ], - { - encoding: 'utf8', - env: { - ...process.env, - HASH_COUNTER: counterPath, - PATH: `${binDir}:${process.env.PATH ?? ''}`, - REWRITE_AFTER: options.rewriteSourceAfterHashCall - ? String(options.rewriteSourceAfterHashCall) - : '', - REWRITE_BYTES: options.rewriteBytes ?? TORN_AUTH, - REWRITE_TARGET: - options.rewriteTarget === 'source-credentials' - ? legacyCredentialsPath - : options.rewriteTarget === 'target-auth' - ? targetAuthPath - : legacyAuthPath - }, - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 20_000 - } - ) - } catch (error) { - status = (error as { status?: number }).status ?? -1 - } - return { - legacyAuth: readFileSync(legacyAuthPath, 'utf8'), - status, - targetAuth: readFileSync(targetAuthPath, 'utf8'), - targetCredentials: existsSync(targetCredentialsPath) - ? readFileSync(targetCredentialsPath, 'utf8') - : null - } -} - -describe.skipIf(isWindows)('legacy WSL auth drain apply script', () => { - it('promotes the validated source into the account home', () => { - const outcome = runApplyScript() - expect(outcome.targetAuth).toBe(SOURCE_AUTH) - }) - - it('leaves the legacy home untouched while promoting', () => { - // One-directional: the promote step must never write back to the old home. - expect(runApplyScript().legacyAuth).toBe(SOURCE_AUTH) - }) - - it('refuses torn bytes and leaves the account home intact', () => { - // Hash call 1 is the source pre-check; rotating right after it means `cp` - // reads bytes freshness never judged. Pre-guard, those reached the target. - const outcome = runApplyScript({ rewriteSourceAfterHashCall: 1 }) - expect(outcome.status).toBe(42) - expect(outcome.targetAuth).toBe(TARGET_AUTH) - }) - - it('refuses MCP credentials that changed after host validation', () => { - const outcome = runApplyScript({ - rewriteSourceAfterHashCall: 2, - rewriteBytes: TORN_CREDENTIALS, - rewriteTarget: 'source-credentials', - sourceCredentials: SOURCE_CREDENTIALS - }) - expect(outcome.status).toBe(43) - expect(outcome.targetCredentials).toBeNull() - }) - - it('does not overwrite auth changed after the destination hash check', () => { - const outcome = runApplyScript({ - rewriteBytes: NEWER_AUTH, - rewriteSourceAfterHashCall: 4, - rewriteTarget: 'target-auth' - }) - expect(outcome.status).toBe(39) - expect(outcome.targetAuth).toBe(NEWER_AUTH) - }) -}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts deleted file mode 100644 index 0c0caa1b669..00000000000 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { runWslProcessMock } = vi.hoisted(() => ({ - runWslProcessMock: vi.fn() -})) - -vi.mock('../wsl/wsl-runner', () => ({ - runWslProcess: runWslProcessMock -})) - -import { - _internals, - drainLegacyWslRuntimeAuth, - startLegacyWslRuntimeAuthDrain -} from './legacy-wsl-runtime-auth-drain' -import { readWslCodexAuths } from './wsl-codex-auth-batch-reader' - -const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' -const STALE_AUTH = '{"tokens":{"expires_at":1000}}\n' -const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' - -function inspection(auth: string, credentials?: string): string { - return [ - Buffer.from(auth).toString('base64'), - credentials === undefined ? 'missing' : 'present', - credentials === undefined ? '' : Buffer.from(credentials).toString('base64') - ].join('\n') -} - -function result(code: number, stdout = '') { - return { - code, - stdout, - stderr: '', - timedOut: false, - environmentResolved: true - } -} - -describe('legacy WSL runtime auth drain', () => { - beforeEach(() => { - runWslProcessMock.mockReset() - _internals.resetDrainQueue() - }) - - it('promotes fresher auth guest-side while a legacy pane remains', async () => { - runWslProcessMock - .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) - .mockResolvedValueOnce(result(0)) - const resolveDestination = vi.fn(() => ({ - authContents: STALE_AUTH, - linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home' - })) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: true, - resolveDestination - }) - - expect(resolveDestination).toHaveBeenCalledWith(SOURCE_AUTH) - expect(runWslProcessMock).toHaveBeenCalledTimes(2) - expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(3)).toEqual([ - '/home/alice/.local/share/orca/codex-accounts/account-1/home', - expect.any(String), - expect.any(String), - '1', - '0', - 'missing' - ]) - expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('readlink -f') - expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('source_credentials=') - expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('chmod 600') - }) - - it('does not write when freshness cannot be proven', async () => { - runWslProcessMock.mockResolvedValueOnce(result(0, inspection('{"tokens":{}}\n'))) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: () => ({ - authContents: '{"tokens":{}}\n', - linuxHomePath: '/home/alice/.codex' - }) - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('refuses a source with no unique destination', async () => { - runWslProcessMock.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: () => null - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('retires stale legacy auth only after the last recorded pane exits', async () => { - runWslProcessMock - .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) - .mockResolvedValueOnce(result(0)) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: () => ({ - authContents: NEWER_AUTH, - linuxHomePath: '/home/alice/.codex' - }) - }) - - expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-3)).toEqual(['0', '1', 'missing']) - }) - - it('keeps an absent source retryable while a legacy pane remains', async () => { - runWslProcessMock.mockResolvedValueOnce(result(21)) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: true, - resolveDestination: vi.fn() - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('does nothing after the guest-side completion marker is present', async () => { - runWslProcessMock.mockResolvedValueOnce(result(20)) - const resolveDestination = vi.fn() - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination - }) - - expect(resolveDestination).not.toHaveBeenCalled() - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('marks an absent source complete after every legacy pane exits', async () => { - runWslProcessMock.mockResolvedValueOnce(result(21)).mockResolvedValueOnce(result(0)) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: vi.fn() - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(2) - expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([ - '/home/alice/.local/share/orca/codex-runtime-home/home', - '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home', - '/home/alice/.local/share/orca/codex-runtime-home/direct-home-auth-drain-v1.json' - ]) - }) - - it('coalesces concurrent drain triggers instead of queueing every poll', async () => { - runWslProcessMock.mockImplementation(() => new Promise(() => {})) - const options = { - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: true, - resolveDestination: () => null - } - - startLegacyWslRuntimeAuthDrain(options) - startLegacyWslRuntimeAuthDrain(options) - await Promise.resolve() - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('reads every candidate home in one bounded guest process', async () => { - runWslProcessMock.mockResolvedValueOnce( - result( - 0, - [`present:${Buffer.from(SOURCE_AUTH).toString('base64')}`, 'missing', 'unreadable'].join( - '\n' - ) - ) - ) - - await expect( - readWslCodexAuths('Ubuntu', ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad']) - ).resolves.toEqual([ - { kind: 'present', contents: SOURCE_AUTH }, - { kind: 'missing' }, - { kind: 'unreadable' } - ]) - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - expect(runWslProcessMock).toHaveBeenCalledWith( - expect.objectContaining({ - args: ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'], - maxOutputBytes: 2 * 1024 * 1024, - timeoutMs: 5_000 - }) - ) - }) -}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts deleted file mode 100644 index bde5d4c9ebc..00000000000 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { createHash } from 'node:crypto' -import { posix as pathPosix } from 'node:path' -import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' -import { runWslProcess } from '../wsl/wsl-runner' -import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity' -import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader' - -const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json' -const MARKER_PRESENT_EXIT = 20 -const SOURCE_AUTH_ABSENT_EXIT = 21 - -export type LegacyWslRuntimeAuthDestination = { - authContents: string - linuxHomePath: string -} - -type LegacyWslRuntimeInspection = { - authContents: string - credentials: { kind: 'missing' } | { kind: 'present'; contents: string } -} - -type LegacyWslRuntimeAuthDrainOptions = { - distro: string - guestHomeLinuxPath: string - legacyPanePresent: boolean - resolveDestination: ( - runtimeAuthContents: string - ) => LegacyWslRuntimeAuthDestination | null | Promise -} - -const drainQueueByDistro = new Map>() -const completedDistroKeys = new Set() - -export function startLegacyWslRuntimeAuthDrain( - options: LegacyWslRuntimeAuthDrainOptions -): Promise { - const key = options.distro.trim().toLowerCase() - if (completedDistroKeys.has(key)) { - return Promise.resolve() - } - // Coalesce launch/rate-limit callers while a drain is in flight. Queuing a - // new pass for every poll can otherwise build an unbounded promise chain - // while a legacy pane keeps the migration pending. - const inFlight = drainQueueByDistro.get(key) - if (inFlight) { - return inFlight - } - const next = drainLegacyWslRuntimeAuth(options) - .then((status) => { - if (status === 'complete') { - completedDistroKeys.add(key) - } - }) - .catch((error) => { - console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error) - }) - drainQueueByDistro.set(key, next) - void next.finally(() => { - if (drainQueueByDistro.get(key) === next) { - drainQueueByDistro.delete(key) - } - }) - return next -} - -export async function drainLegacyWslRuntimeAuth( - options: LegacyWslRuntimeAuthDrainOptions -): Promise<'complete' | 'pending'> { - const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath) - const inspection = await runWslProcess({ - distro: options.distro, - loginPath: 'none', - script: INSPECT_LEGACY_AUTH_SCRIPT, - args: [paths.runtimeHome, paths.activeHome, paths.marker], - timeoutMs: 5_000, - maxOutputBytes: 2 * 1024 * 1024 - }) - if (inspection.code === MARKER_PRESENT_EXIT) { - return 'complete' - } - if (inspection.code === SOURCE_AUTH_ABSENT_EXIT) { - if (!options.legacyPanePresent) { - await finalizeAbsentLegacyAuth(options.distro, paths) - return 'complete' - } - return 'pending' - } - assertSuccessfulDrainStep('inspect', inspection) - - const inspected = parseLegacyRuntimeInspection(inspection.stdout) - if (!inspected) { - return 'pending' - } - const destination = await options.resolveDestination(inspected.authContents) - if (!destination) { - return 'pending' - } - const freshness = compareCodexAuthFreshness(inspected.authContents, destination.authContents) - if (freshness === null) { - return 'pending' - } - const promoteAuth = codexAuthIsFresher(inspected.authContents, destination.authContents) - const result = await runWslProcess({ - distro: options.distro, - loginPath: 'none', - script: APPLY_LEGACY_AUTH_SCRIPT, - args: [ - paths.runtimeHome, - paths.activeHome, - paths.marker, - destination.linuxHomePath, - sha256(inspected.authContents), - sha256(destination.authContents), - promoteAuth ? '1' : '0', - options.legacyPanePresent ? '0' : '1', - inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing' - ], - timeoutMs: 5_000, - maxOutputBytes: 16 * 1024 - }) - assertSuccessfulDrainStep('apply', result) - return options.legacyPanePresent ? 'pending' : 'complete' -} - -function parseLegacyRuntimeInspection(stdout: string): LegacyWslRuntimeInspection | null { - const [authBase64, credentialsKind, credentialsBase64] = stdout.split('\n') - const authContents = decodeWslBase64Payload(authBase64 ?? '') - if (authContents === null) { - return null - } - if (credentialsKind === 'missing') { - return { authContents, credentials: { kind: 'missing' } } - } - if (credentialsKind !== 'present') { - return null - } - const credentialsContents = decodeWslBase64Payload(credentialsBase64 ?? '') - if (!credentialsContents || !isJsonObject(credentialsContents)) { - return null - } - return { authContents, credentials: { kind: 'present', contents: credentialsContents } } -} - -function isJsonObject(contents: string): boolean { - try { - const value = JSON.parse(contents) as unknown - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) - } catch { - return false - } -} - -function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): { - activeHome: string - marker: string - runtimeHome: string -} { - const runtimeHome = pathPosix.join(guestHomeLinuxPath, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) - const runtimeRoot = pathPosix.dirname(runtimeHome) - return { - activeHome: pathPosix.join(runtimeRoot, 'active', 'wsl', 'home'), - marker: pathPosix.join(runtimeRoot, DRAIN_MARKER_NAME), - runtimeHome - } -} - -async function finalizeAbsentLegacyAuth( - distro: string, - paths: ReturnType -): Promise { - const result = await runWslProcess({ - distro, - loginPath: 'none', - script: FINALIZE_ABSENT_AUTH_SCRIPT, - args: [paths.runtimeHome, paths.activeHome, paths.marker], - timeoutMs: 5_000, - maxOutputBytes: 16 * 1024 - }) - assertSuccessfulDrainStep('finalize', result) -} - -function assertSuccessfulDrainStep( - step: string, - result: { code: number | null; stderr: string; timedOut: boolean } -): void { - if (result.code === 0 && !result.timedOut) { - return - } - const detail = result.stderr.trim() - throw new Error( - `Legacy WSL auth drain ${step} failed (${result.timedOut ? 'timeout' : `exit ${result.code}`})${detail ? `: ${detail}` : ''}` - ) -} - -function sha256(contents: string): string { - return createHash('sha256').update(contents).digest('hex') -} - -const RESOLVE_LEGACY_HOME_SCRIPT = ` -legacy_home="$1" -legacy_home_resolved=0 -if [ -e "$1" ] || [ -L "$1" ]; then - legacy_home=$(readlink -f -- "$1") || exit 30 - legacy_home_resolved=1 -fi -if [ -e "$2" ] || [ -L "$2" ]; then - active_home=$(readlink -f -- "$2") || exit 31 - if [ "$legacy_home_resolved" = 1 ]; then - [ "$active_home" = "$legacy_home" ] || exit 32 - else - legacy_home="$active_home" - fi -fi -` - -const INSPECT_LEGACY_AUTH_SCRIPT = ` -set -eu -[ ! -f "$3" ] || exit ${MARKER_PRESENT_EXIT} -${RESOLVE_LEGACY_HOME_SCRIPT} -source_auth="$legacy_home/auth.json" -[ -f "$source_auth" ] || exit ${SOURCE_AUTH_ABSENT_EXIT} -encode_file() { - encoded=$(base64 < "$1") || return 1 - printf '%s' "$encoded" | tr -d '\n' -} -encode_file "$source_auth" -printf '\n' -source_credentials="$legacy_home/.credentials.json" -if [ -f "$source_credentials" ]; then - printf 'present\n' - encode_file "$source_credentials" - printf '\n' -elif [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ]; then - printf 'missing\n\n' -else - exit 44 -fi -` - -const APPLY_LEGACY_AUTH_SCRIPT = ` -set -eu -[ ! -f "$3" ] || exit 0 -${RESOLVE_LEGACY_HOME_SCRIPT} -target_home=$(readlink -f -- "$4") || exit 33 -[ "$legacy_home" != "$target_home" ] || exit 34 -source_auth="$legacy_home/auth.json" -target_auth="$target_home/auth.json" -[ -f "$source_auth" ] || exit 35 -[ -f "$target_auth" ] || exit 36 -hash_file() { sha256sum -- "$1" | cut -d ' ' -f 1; } -[ "$(hash_file "$source_auth")" = "$5" ] || exit 37 -[ "$(hash_file "$target_auth")" = "$6" ] || exit 38 -umask 077 -temporary_auth="$target_auth.orca-drain-$$" -temporary_credentials="$target_home/.credentials.json.orca-drain-$$" -temporary_previous_auth="$target_auth.orca-drain-previous-$$" -temporary_marker="$3.orca-drain-$$" -cleanup() { rm -f -- "$temporary_auth" "$temporary_credentials" "$temporary_previous_auth" "$temporary_marker"; } -trap cleanup EXIT HUP INT TERM -source_credentials="$legacy_home/.credentials.json" -target_credentials="$target_home/.credentials.json" -if [ -f "$source_credentials" ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then - [ "$9" != missing ] || exit 43 - [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 - cp -- "$source_credentials" "$temporary_credentials" - chmod 600 "$temporary_credentials" - [ "$(hash_file "$temporary_credentials")" = "$9" ] || exit 43 - [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 - mv -n -- "$temporary_credentials" "$target_credentials" -elif [ "$9" = missing ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then - [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ] || exit 43 -fi -if [ "$7" = 1 ]; then - cp -- "$source_auth" "$temporary_auth" - chmod 600 "$temporary_auth" - # Codex rewrites auth.json in place, so this copy is a second read: verify the - # bytes being promoted, not the ones freshness was judged on. - [ "$(hash_file "$temporary_auth")" = "$5" ] || exit 42 - [ "$(hash_file "$target_auth")" = "$6" ] || exit 39 - # The hard link keeps the destination inode observable without creating a - # missing-path crash window. In-place writers update both names. - ln -- "$target_auth" "$temporary_previous_auth" - [ "$(hash_file "$temporary_previous_auth")" = "$6" ] || exit 39 - mv -f -- "$temporary_auth" "$target_auth" - if [ "$(hash_file "$temporary_previous_auth")" != "$6" ]; then - mv -f -- "$temporary_previous_auth" "$target_auth" - exit 39 - fi - rm -- "$temporary_previous_auth" -fi -if [ "$8" = 1 ]; then - [ "$(hash_file "$source_auth")" = "$5" ] || exit 40 - rm -- "$source_auth" - printf '%s\n' '{"completed":true}' > "$temporary_marker" - chmod 600 "$temporary_marker" - mv -f -- "$temporary_marker" "$3" -fi -` - -const FINALIZE_ABSENT_AUTH_SCRIPT = ` -set -eu -[ ! -f "$3" ] || exit 0 -${RESOLVE_LEGACY_HOME_SCRIPT} -[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41 -umask 077 -temporary_marker="$3.orca-drain-$$" -trap 'rm -f -- "$temporary_marker"' EXIT HUP INT TERM -printf '%s\n' '{"completed":true}' > "$temporary_marker" -chmod 600 "$temporary_marker" -mv -f -- "$temporary_marker" "$3" -` - -export const _internals = { - applyLegacyAuthScript: APPLY_LEGACY_AUTH_SCRIPT, - resetDrainQueue: (): void => { - drainQueueByDistro.clear() - completedDistroKeys.clear() - } -} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 7975c7172ad..cf3a0105d63 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,4 +1,5 @@ /* 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, @@ -17,6 +18,7 @@ import { unlinkSync } from 'node:fs' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { execFileSync } from 'node:child_process' import { dirname, extname, @@ -31,6 +33,7 @@ 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, @@ -52,13 +55,16 @@ import { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' -import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { + prepareSystemConfigForFreshRuntimeMirror, + 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' @@ -85,16 +91,10 @@ 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,6 +130,7 @@ type CodexRuntimeLogoutMarkerStatus = | { kind: 'applies' } | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } +type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' type CodexReadBackMatch = | { kind: 'matched' @@ -194,6 +195,11 @@ export class CodexRuntimeHomeService { private lastSyncedAccountId: string | null = null // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). private lastWrittenAuthJson: string | null = null + // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. + private readonly lastWrittenWslAuthJsonByDistro = new Map() + private readonly lastSyncedWslAccountIdByDistro = new Map() + private readonly wslRuntimeHomePathByDistro = new Map() + private skipNextReadBackForAccountId: string | null = null // Why: a managed host account refreshes auth in its own home. Remember that // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false @@ -238,11 +244,11 @@ export class CodexRuntimeHomeService { ): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) - this.startLegacyWslAuthDrain(wslTarget) - this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, homePath) - this.startWslSessionBridgeForLaunch(wslTarget, homePath) - return homePath + const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) + this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) + const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) + return runtimeHomePath } const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { @@ -718,6 +724,26 @@ 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 @@ -764,9 +790,10 @@ 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: this.getPreparedWslRateLimitHomePath(wslTarget) + codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } } const selfContainedAccount = this.getSelfContainedManagedHostAccount() @@ -806,7 +833,7 @@ export class CodexRuntimeHomeService { launchEnv?: NodeJS.ProcessEnv ): void { if (target?.runtime === 'wsl') { - this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) + this.syncWslRuntimeForCurrentSelection(target) return } @@ -853,6 +880,7 @@ 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) { @@ -894,13 +922,71 @@ export class CodexRuntimeHomeService { } } - // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. + // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. 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 @@ -965,152 +1051,223 @@ export class CodexRuntimeHomeService { } private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - return this.getWslCodexHomePathForSelection(target) - } - - private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { - const settings = this.store.getSettings() - const account = this.getActiveAccount( - settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, target) - ) - if (account) { - const managedHome = parseWslUncPath(account.managedHomePath) - const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() - // Persisted selections can outlive an account's runtime metadata. Never - // hand a host home (or another distro's UNC home) to a WSL launch. - const accountDistro = account.wslDistro?.trim() - const distroMatches = - (!accountDistro || - !targetDistro || - accountDistro.toLowerCase() === targetDistro.toLowerCase()) && - (!managedHome || - !targetDistro || - managedHome.distro.toLowerCase() === targetDistro.toLowerCase()) - // Older persisted fixtures may not carry a UNC spelling, but the - // explicit WSL runtime marker still proves the lane; reject unmarked - // host paths while retaining those legacy WSL records. - if (distroMatches && (account.managedHomeRuntime === 'wsl' || managedHome)) { - return account.managedHomePath + 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.getWslSystemCodexHomePath(target) + return this.syncWslRuntimeForCurrentSelection(target) } - private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): Promise { + private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { - return Promise.resolve() + return null } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() + + const wslTarget = this.resolveWslDefaultTarget(target) + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, wslTarget) + ) + const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() if (!distro) { - return Promise.resolve() + return null } - const guestHome = getWslHome(distro) - const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null - if (!parsedGuestHome) { - return Promise.resolve() + + const runtimeHomePath = this.getWslRuntimeHomePath(distro) + if (!runtimeHomePath) { + return null } - let legacyPanePresent: boolean - try { - legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) - } catch (error) { - console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) - 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 + }) + } + } } - return startLegacyWslRuntimeAuthDrain({ - distro, - guestHomeLinuxPath: parsedGuestHome.linuxPath, - legacyPanePresent, - resolveDestination: (runtimeAuthContents) => - this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) - }) + + 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 } - /** Preserve refreshed auth from retained legacy WSL panes before restart. */ - async syncActiveWslSelectionsBeforeRestart(): Promise { - if (process.platform !== 'win32') { + private getWslRuntimeHomePath(distro: string): string | null { + const home = getWslHome(distro) + return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null + } + + private safeReadBackActiveWslAccountBeforeRestart( + account: CodexManagedAccount, + selectedDistroKey: string + ): void { + try { + this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey) + } catch (error) { + console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error) + } + } + + private readBackActiveWslAccountBeforeRestart( + account: CodexManagedAccount, + selectedDistroKey: string + ): void { + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() + : selectedDistroKey.trim() || account.wslDistro?.trim() + if (!distro) { return } - const settings = this.store.getSettings() - const drains: Promise[] = [] - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() || null - : selectedDistroKey.trim() || null - if (distro) { - drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) - } + + const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) + if (!runtimeHomePath) { + return } - await Promise.all(drains) + + this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: account.id + }) } - private async resolveLegacyWslAuthDestination( - distro: string, - runtimeAuthContents: string - ): Promise { - const accounts = this.store.getSettings().codexManagedAccounts.filter((account) => { - const parsedHome = parseWslUncPath(account.managedHomePath) - return parsedHome?.distro.toLowerCase() === distro.toLowerCase() - }) - const accountHomes = accounts.flatMap((account) => { - const parsedHome = parseWslUncPath(account.managedHomePath) - return parsedHome ? [{ account, linuxPath: parsedHome.linuxPath }] : [] - }) - const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null - let reads: WslCodexAuthRead[] + private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { try { - reads = await readWslCodexAuths(distro, [ - ...accountHomes.map(({ linuxPath }) => linuxPath), - ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) - ]) - } catch { - reads = accountHomes.map(() => ({ kind: 'unreadable' })) - if (parsedSystemHome) { - reads.push({ kind: 'unreadable' }) - } - } - const authReads = new Map( - accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) - ) - const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { - accounts, - authReads - }) - if (match.kind === 'ambiguous') { - return null - } - if (match.kind === 'matched') { - const parsedHome = parseWslUncPath(match.account.managedHomePath) - if (!parsedHome || parsedHome.distro.toLowerCase() !== distro.toLowerCase()) { - return null - } - return { - authContents: match.managedAuthContents, - linuxHomePath: parsedHome.linuxPath - } + this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath) + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error) } + } - if (!systemHome || !parsedSystemHome) { - return null + private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { + const runtimeWsl = parseWslUncPath(runtimeHomePath) + if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) { + return } - 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 + const activeLinuxPath = runtimeWsl.linuxPath.replace( + /\/codex-runtime-home\/home$/, + '/codex-runtime-home/active/wsl/home' + ) + const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` + const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) + // Why: login-shell cleanup turns `exit 0` into status 1, so fall through. + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--exec', + 'bash', + '-lc', + [ + 'set -e', + `if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, + `elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, + 'else', + `mkdir -p ${quotePosixShell(activeLinuxParentPath)}`, + `rm -rf -- ${quotePosixShell(nextLinuxPath)}`, + `ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`, + `mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`, + 'fi' + ].join('\n') + ], + // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes + // a conhost and steals foreground for up to the timeout (#10488). + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } + ) + } + + private dirnameLinuxPath(value: string): string { + const index = value.lastIndexOf('/') + return index > 0 ? value.slice(0, index) : '/' } private joinWslPath(basePath: string, ...segments: string[]): string { @@ -1129,13 +1286,40 @@ 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, - options?: { - accounts: readonly CodexManagedAccount[] - authReads: ReadonlyMap - } + expectedAccountId?: string ): CodexReadBackMatch { const matches: { account: CodexManagedAccount @@ -1143,17 +1327,18 @@ export class CodexRuntimeHomeService { managedAuthContents: string }[] = [] let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { + for (const account of this.store.getSettings().codexManagedAccounts) { if (expectedAccountId && account.id !== expectedAccountId) { continue } const managedAuthPath = join(account.managedHomePath, 'auth.json') - let managedAuthContents: string - const suppliedRead = options?.authReads.get(account.id) - if (suppliedRead?.kind === 'missing') { + if (!existsSync(managedAuthPath)) { continue } - if (suppliedRead?.kind === 'unreadable') { + let managedAuthContents: string + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { // 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 @@ -1166,24 +1351,6 @@ 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 }) } @@ -1205,6 +1372,10 @@ 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 { @@ -1911,6 +2082,15 @@ 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 @@ -2217,3 +2397,14 @@ export class CodexRuntimeHomeService { rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) } } + +// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2). +export function prepareWslRuntimeSeedConfig( + configContents: string, + sourceHomePath: string +): string { + return prepareSystemConfigForFreshRuntimeMirror( + configContents, + parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath + ) +} diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index 81593c46013..efc7ea3f2b9 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { createSettings } from './runtime-home-settings-test-fixtures' import { @@ -87,16 +87,18 @@ 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( - wslManagedHomePath + wslRuntimeHomePath + ) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( + '{"account":"wsl"}\n' ) - 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: wslManagedHomePath + codexHomePath: wslRuntimeHomePath }) } finally { if (originalPlatform) { @@ -105,7 +107,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('keeps a selected WSL managed home when auth.json is temporarily missing', async () => { + it('clears a selected WSL managed account when auth.json is missing', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -159,11 +161,13 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - managedHomePath + wslRuntimeHomePath ) - expect(store.updateSettings).not.toHaveBeenCalled() - expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(store.updateSettings).toHaveBeenCalledWith({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -171,7 +175,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('launches WSL system default against its existing config without a runtime seed', async () => { + it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -210,18 +214,21 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - systemCodexHomePath + wslRuntimeHomePath ) const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') - 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.') + 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.') - writeFileSync(systemConfigPath, `${systemConfig}\n[projects."/tmp/x"]\n`, 'utf-8') + // 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') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(systemConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -229,7 +236,26 @@ describe('CodexRuntimeHomeService', () => { } }) - it('switches WSL accounts by selecting each account home directly', async () => { + 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 () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -291,17 +317,16 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - expect(service.prepareForCodexLaunch(target)).toBe(firstManagedHomePath) - expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) store.updateSettings({ activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } } }) service.syncForCurrentSelection(target) - 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) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -309,7 +334,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('ignores stale retired runtime auth when launching a managed WSL account', async () => { + it('does not use host auth baseline to accept stale WSL runtime auth', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -317,6 +342,7 @@ describe('CodexRuntimeHomeService', () => { getDefaultWslDistro: () => 'Ubuntu', getWslHome: () => wslHome })) + const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token') const wslManagedAuth = createCodexAuthJson( 'wsl@example.com', 'acct-wsl', @@ -329,6 +355,7 @@ describe('CodexRuntimeHomeService', () => { 'runtime-stale', 1_000 ) + const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) const wslManagedHomePath = createManagedAuth( testState.userDataDir, 'wsl-account', @@ -347,6 +374,17 @@ 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', @@ -357,13 +395,14 @@ describe('CodexRuntimeHomeService', () => { providerAccountId: 'acct-wsl', workspaceLabel: null, workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 } ], + activeCodexManagedAccountId: 'host-account', activeCodexManagedAccountIdsByRuntime: { - host: null, + host: 'host-account', wsl: { Ubuntu: 'wsl-account' } } }) @@ -374,10 +413,148 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslManagedHomePath + wslRuntimeHomePath ) expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth) + 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) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index fe91d223a40..05cfa12e240 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, 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,7 +34,62 @@ describe('CodexRuntimeHomeService', () => { teardownRuntimeHomeTest() }) - it('skips WSL session bridging when system default already uses its direct home', async () => { + 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 () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -69,13 +124,17 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslSystemHomePath + wslRuntimeHomePath ) - expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled() - expect(readFileSync(join(wslSystemHomePath, 'AGENTS.md'), 'utf-8')).toBe( - '# WSL instructions\n' - ) - expect(existsSync(join(wslRuntimeHomePath, 'AGENTS.md'))).toBe(false) + 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) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -85,7 +144,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('keeps WSL in-Codex setting changes in the direct system home', async () => { + it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) vi.doMock('../codex/wsl-codex-session-bridge', () => ({ @@ -118,20 +177,32 @@ describe('CodexRuntimeHomeService', () => { 'home' ) + // First launch seeds the runtime config and records the per-distro baseline. expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - join(wslHome, '.codex') + wslRuntimeHomePath ) const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json') - expect(existsSync(baselinePath)).toBe(false) + expect(existsSync(baselinePath)).toBe(true) + // 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(wslSystemConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + 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' + ) - writeFileSync(wslSystemConfigPath, 'model = "o4"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n') - expect(existsSync(baselinePath)).toBe(false) + // Baseline advances so the promoted value is not re-promoted forever. + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -165,13 +236,22 @@ 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: join(wslHome, '.codex') + managedCodexHomePath: wslRuntimeHomePath }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') @@ -182,7 +262,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging for the selected direct account home', async () => { + it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -190,6 +270,14 @@ 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) @@ -199,10 +287,10 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate.includes('codex-accounts/debian-account/home') + candidate === wslRuntimeHomePath ? { distro: 'Debian', - linuxPath: '/home/alice/.local/share/orca/codex-accounts/debian-account/home' + linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' } : null } @@ -240,12 +328,12 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe( - managedHomePath + wslRuntimeHomePath ) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Debian', systemCodexHomePath: join(wslHome, '.codex'), - managedCodexHomePath: managedHomePath + managedCodexHomePath: wslRuntimeHomePath }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index 8e8965f6a40..a1ad86a0a5f 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -53,6 +53,15 @@ 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') @@ -64,6 +73,7 @@ describe('CodexRuntimeHomeService', () => { kind: 'ready', codexHomePath: expectedHome }) + expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -83,6 +93,15 @@ 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: [ @@ -128,9 +147,9 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({ kind: 'ready', - codexHomePath: ubuntuHomePath + codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') }) - expect(readFileSync(join(ubuntuHomePath, 'auth.json'), 'utf-8')).toBe(ubuntuAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -209,7 +228,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('keeps WSL system-default token refreshes in its direct home', async () => { + it('reads WSL system-default token refreshes back to WSL system auth', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -238,11 +257,21 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) - writeFileSync(join(systemCodexHomePath, 'auth.json'), refreshedAuth, 'utf-8') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) - expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) 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) @@ -250,7 +279,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('does not overwrite direct WSL system auth from the retired runtime on restart', async () => { + it('preserves WSL system-default token refreshes after app restart', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -290,8 +319,8 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { diff --git a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts deleted file mode 100644 index 40870fcd5ef..00000000000 --- a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { runWslProcess } from '../wsl/wsl-runner' - -export type WslCodexAuthRead = - | { kind: 'missing' | 'unreadable' } - | { kind: 'present'; contents: string } - -export async function readWslCodexAuths( - distro: string, - linuxHomePaths: readonly string[] -): Promise { - if (linuxHomePaths.length === 0) { - return [] - } - const result = await runWslProcess({ - distro, - loginPath: 'none', - script: READ_AUTHS_SCRIPT, - args: linuxHomePaths, - timeoutMs: 5_000, - maxOutputBytes: 2 * 1024 * 1024 - }) - if (result.code !== 0 || result.timedOut) { - return linuxHomePaths.map(() => ({ kind: 'unreadable' })) - } - const rows = result.stdout.split('\n') - return linuxHomePaths.map((_, index) => parseAuthReadRow(rows[index] ?? '')) -} - -export function decodeWslBase64Payload(encoded: string): string | null { - try { - const decoded = Buffer.from(encoded, 'base64') - const canonical = decoded.toString('base64').replace(/=+$/, '') - return canonical === encoded.replace(/=+$/, '') ? decoded.toString('utf8') : null - } catch { - return null - } -} - -function parseAuthReadRow(row: string): WslCodexAuthRead { - if (row === 'missing' || row === 'unreadable') { - return { kind: row } - } - if (!row.startsWith('present:')) { - return { kind: 'unreadable' } - } - const contents = decodeWslBase64Payload(row.slice('present:'.length)) - return contents === null ? { kind: 'unreadable' } : { kind: 'present', contents } -} - -const READ_AUTHS_SCRIPT = ` -set -eu -for home_path in "$@"; do - auth_path="$home_path/auth.json" - if [ ! -f "$auth_path" ]; then - if [ ! -e "$auth_path" ] && [ ! -L "$auth_path" ]; then - printf 'missing\n' - else - printf 'unreadable\n' - fi - continue - fi - if encoded=$(base64 < "$auth_path"); then - printf 'present:' - printf '%s' "$encoded" | tr -d '\n' - printf '\n' - else - printf 'unreadable\n' - fi -done -` diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 4a4467bd219..449c11ba711 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -266,15 +266,6 @@ export function hasRecordedLegacySharedCodexPane(): boolean { ) } -/** True when a retained WSL pane may still read the retired per-distro runtime home. */ -export function hasRecordedLegacyWslCodexPane(selectionKey: string): boolean { - return Object.values(readRegistryOrThrow().panes).some( - (record) => - (record.selectionKey === selectionKey || record.selectionKey === 'wsl:__default__') && - (record.homeRoute === undefined || record.homeRoute === 'wsl-home') - ) -} - /** True when startup may need to repair hooks for a retained managed host pane. */ export function hasRecordedManagedHostCodexPane(): boolean { return Object.values(readRegistry().panes).some( diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index c862099e3ac..456b9bc08be 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -202,7 +202,7 @@ describe('resolveCodexPaneLaunchAccount', () => { ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account', - homeRoute: 'account-home' + homeRoute: 'wsl-home' }) }) diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index 089c1975db3..b99700da081 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -1,6 +1,5 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { parseWslUncPath } from '../../shared/wsl-paths' import { getCodexSelectionLaneKey, getCodexSelectionTargetForAccount, @@ -81,6 +80,9 @@ function resolveCodexPaneHomeRoute(args: { settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneHomeRoute { + if (args.target.runtime === 'wsl') { + return 'wsl-home' + } if ( !args.launchCodexHomePath || normalizeRuntimePathForComparison(args.launchCodexHomePath) === @@ -91,19 +93,10 @@ function resolveCodexPaneHomeRoute(args: { const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) const accountOwnsHome = args.settings.codexManagedAccounts?.some( (account) => - getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) === - getCodexSelectionLaneKey(args.target) && + getCodexSelectionTargetForAccount(account).runtime === 'host' && normalizeRuntimePathForComparison(account.managedHomePath) === launchHome ) - if (accountOwnsHome) { - return 'account-home' - } - if (args.target.runtime === 'wsl') { - return parseWslUncPath(args.launchCodexHomePath)?.linuxPath.endsWith('/.codex') - ? 'real-home' - : 'wsl-home' - } - return 'shared-home' + return accountOwnsHome ? 'account-home' : 'shared-home' } /** undefined when no account owns the home; null means the system-default account. */ diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index c5deb104543..bf2f7e5b5ea 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -8,7 +8,6 @@ import { forgetCodexPaneAccount, getCodexPaneAccount, hasRecordedLegacySharedCodexPane, - hasRecordedLegacyWslCodexPane, hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, @@ -139,36 +138,6 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) - it('identifies only legacy runtime-home panes on the requested WSL lane', () => { - recordCodexPaneAccount('pty-legacy', { - selectionKey: 'wsl:Ubuntu', - accountId: 'account-old', - homeRoute: 'wsl-home' - }) - recordCodexPaneAccount('pty-direct', { - selectionKey: 'wsl:Ubuntu', - accountId: 'account-new', - homeRoute: 'account-home' - }) - recordCodexPaneAccount('pty-other-distro', { - selectionKey: 'wsl:Debian', - accountId: 'account-debian', - homeRoute: 'wsl-home' - }) - recordCodexPaneAccount('pty-default', { - selectionKey: 'wsl:__default__', - accountId: null, - homeRoute: 'wsl-home' - }) - - expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) - forgetCodexPaneAccount('pty-legacy') - expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) - forgetCodexPaneAccount('pty-default') - expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false) - expect(hasRecordedLegacyWslCodexPane('wsl:Debian')).toBe(true) - }) - it('requests startup inventory only for managed host panes', () => { recordCodexPaneAccount('pty-real', { selectionKey: 'host', diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index 6667e3fd608..f61272de8c3 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -1,4 +1,6 @@ -/** Guest-relative layout of Orca's retired WSL CODEX_HOME, retained for migration reads. */ +/** 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. */ export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [ '.local', 'share', diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index e226814ba84..befefb5a2c8 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -17,6 +17,7 @@ main/agent-hooks/wsl-hook-relay-launch.ts main/browser/wsl-browser-network-relay-launch.ts main/claude-accounts/claude-command-process.ts +main/codex-accounts/runtime-home-service.ts main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index dbe9c0c2aa4..03e06eb374f 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -41,6 +41,7 @@ src/main/browser/browser-cookie-import.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts src/main/claude-accounts/keychain.ts +src/main/codex-accounts/runtime-home-service.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-session.ts From aaef5e8c9fbd201cfb8003f268badfc6b7a1610b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:17:34 -0700 Subject: [PATCH 012/118] fix(agent-hooks): deliver hook events that fire while Orca is restarting (STA-5329) (#16685) * fix(agent-hooks): correct durable spool delivery * fix(agent-hooks): spool curl failures after retries * fix(agent-hooks): keep replay out of runtime observations * test(agent-hooks): pin managed hooks inert outside an Orca terminal * fix(agent-hooks): address review findings on the durable spool - claude: pass the literal source; options.agent does not exist (typecheck) - kimi: the windows-local ordering runs its guard pre-stdin and before the function exists, so it no longer spools there (printed command-not-found) - writer: require a readable endpoint file before creating a spool tree - antigravity: carry its out-of-band event name into the record and filter on it - drain: truncate only the bytes consumed, preserving concurrent appends and a torn trailing line * fix(agent-hooks): ignore spool events without pane attribution * fix(agent-hooks): make spool replay and appends robust * test(agent-hooks): type spool replay records * fix(agent-hooks): defer unterminated spool records * fix(agent-hooks): replay spool events through relays * fix(agent-hooks): preserve Codex prompt across child replay * fix(relay): keep startup alive when spool replay fails * fix(relay): simplify spool replay startup guard --- .../hook-script-outside-orca.test.ts | 82 +++++ src/main/agent-hooks/hook-stdin-contract.ts | 46 +++ .../server-relay-listener-replay.test.ts | 1 + src/main/agent-hooks/server.ts | 71 ++++- src/main/agent-hooks/spool.test.ts | 289 ++++++++++++++++++ src/main/antigravity/hook-script.ts | 5 +- src/main/claude/hook-service.ts | 5 +- src/main/codex/hook-service.ts | 9 +- .../command-code-managed-script.ts | 5 +- src/main/copilot/copilot-managed-script.ts | 9 +- src/main/cursor/hook-service.ts | 5 +- src/main/devin/hook-service.ts | 5 +- src/main/droid/hook-service.ts | 5 +- src/main/gemini/hook-service.ts | 5 +- src/main/grok/grok-hook-script.ts | 9 +- src/main/kimi/hook-service.ts | 22 +- src/relay/agent-hook-server.test.ts | 75 ++++- src/relay/agent-hook-server.ts | 40 ++- .../providers/codex-state.ts | 7 + src/shared/agent-hook-spool.ts | 172 +++++++++++ 20 files changed, 844 insertions(+), 23 deletions(-) create mode 100644 src/main/agent-hooks/hook-script-outside-orca.test.ts create mode 100644 src/main/agent-hooks/spool.test.ts create mode 100644 src/shared/agent-hook-spool.ts diff --git a/src/main/agent-hooks/hook-script-outside-orca.test.ts b/src/main/agent-hooks/hook-script-outside-orca.test.ts new file mode 100644 index 00000000000..6e9a27f6acc --- /dev/null +++ b/src/main/agent-hooks/hook-script-outside-orca.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { _internals as codexInternals } from '../codex/hook-service' +import { buildPosixHookSpoolLines } from './hook-stdin-contract' + +/** Managed hooks are installed into the user's agent config, so they also run when the + * agent is launched from a plain terminal. There they must be inert and silent. */ +function runHook(dir: string, extraEnv: NodeJS.ProcessEnv = {}) { + const script = join(dir, 'codex-hook.sh') + writeFileSync(script, codexInternals.getManagedScript('posix')) + chmodSync(script, 0o755) + const clean: NodeJS.ProcessEnv = {} + for (const [k, v] of Object.entries(process.env)) { + if (!k.startsWith('ORCA_')) { + clean[k] = v + } + } + return spawnSync('/bin/sh', [script], { + input: '{"hook_event_name":"SubagentStop","agent_id":"child"}\n', + env: { ...clean, ...extraEnv }, + timeout: 5000, + encoding: 'utf8' + }) +} + +describe('managed hook outside an Orca terminal', () => { + it('no Orca env at all: silent, exit 0, writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-')) + const res = runHook(dir) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('pane key present but no endpoint: still silent and writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-partial-')) + const res = runHook(dir, { ORCA_PANE_KEY: 'tab:0', ORCA_TAB_ID: 'tab' }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('endpoint points at a path that does not exist: silent, exit 0', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-stale-')) + const res = runHook(dir, { + ORCA_AGENT_HOOK_ENDPOINT: join(dir, 'gone', 'deeper', 'endpoint.env'), + ORCA_PANE_KEY: 'tab:0' + }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + // a stale env var must not create a spool tree for an Orca that is not installed here + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('readable endpoint without a pane key: silent, writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-readable-')) + const endpoint = join(dir, 'endpoint.env') + writeFileSync(endpoint, 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\n') + const res = runHook(dir, { ORCA_AGENT_HOOK_ENDPOINT: endpoint }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir).sort()).toEqual(['codex-hook.sh', 'endpoint.env']) + }) +}) + +describe('antigravity out-of-band event name', () => { + it('records hookEventName and filters tool progress on it', () => { + const lines = buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT').join('\n') + expect(lines).toContain('"hookEventName":"%s"') + expect(lines).toContain('${ORCA_ANTIGRAVITY_EVENT:-}') + expect(lines).toContain('in PreToolUse|PostToolUse|PostToolUseFailure) return 0') + // payload-based filtering stays the default for every other provider + expect(buildPosixHookSpoolLines('codex').join('\n')).toContain('case "$payload" in') + }) +}) diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index fb7b3d85487..acba7927650 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -22,6 +22,52 @@ export function buildPosixHookPayloadCapture( ] } +/** Shell-side durable fallback shared by every POSIX managed hook. + * `eventNameVar` is for providers that send the event name out-of-band rather than in the + * payload JSON; without it both the progress filter and replay would miss the event name. */ +export function buildPosixHookSpoolLines(source: string, eventNameVar?: string): string[] { + // Why: the event name must be a printf ARG, not inlined in the single-quoted format, + // where a command substitution would be emitted literally. + const eventFormat = eventNameVar ? '"hookEventName":"%s",' : '' + const eventArg = eventNameVar ? ` "$(spool_json_escape "\${${eventNameVar}:-}")"` : '' + const spoolRecordLine = " { printf '\\n{".concat( + eventFormat, + '"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"payload":%s}\\n\'', + eventArg, + ' "$(spool_json_escape "${ORCA_PANE_KEY:-}")" "$(spool_json_escape "${ORCA_TAB_ID:-}")" "$(spool_json_escape "${ORCA_WORKTREE_ID:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_ENV:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_VERSION:-}")" "$(spool_json_escape "${ORCA_AGENT_LAUNCH_TOKEN:-}")" "$(spool_json_escape "', + source, + '")" "$spool_now" "$payload"; } >> "$spool_file" 2>/dev/null || :' + ) + return [ + 'spool_hook_event() {', + eventNameVar + ? ` case "\${${eventNameVar}:-}" in PreToolUse|PostToolUse|PostToolUseFailure) return 0 ;; esac` + : ' case "$payload" in *\'"PreToolUse"\'*|*\'"PostToolUse"\'*|*\'"PostToolUseFailure"\'*) return 0 ;; esac', + ' [ -n "${ORCA_AGENT_HOOK_ENDPOINT:-}" ] || return 0', + // Why: an endpoint can linger in a parent shell after leaving Orca; without a pane key + // the record is un-attributable and would accumulate as pane-unknown.jsonl. + ' [ -n "${ORCA_PANE_KEY:-}" ] || return 0', + // Why: a stale env var must not create a spool tree for an Orca that is not installed here. + ' [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ] || return 0', + ' spool_base=${ORCA_AGENT_HOOK_ENDPOINT%/*}', + ' spool_dir="$spool_base/spool"', + ' mkdir -p "$spool_dir" 2>/dev/null || return 0', + ' chmod 700 "$spool_dir" 2>/dev/null || :', + " spool_id=$(printf %s \"${ORCA_PANE_KEY:-unknown}\" | tail -c 36 | tr '/:' '__')", + ' spool_file="$spool_dir/pane-$spool_id.jsonl"', + ' if [ -f "$spool_file" ] && find "$spool_file" -mtime +7 -print -quit 2>/dev/null | grep -q .; then : > "$spool_file"; fi', + ' [ -f "$spool_file" ] || : > "$spool_file"', + ' spool_size=$(wc -c < "$spool_file" 2>/dev/null || printf 0)', + ' [ "$spool_size" -lt 5242880 ] || return 0', + ' spool_now=$(date +%s 2>/dev/null || printf 0)', + ' spool_now=$((spool_now * 1000))', + ' spool_json_escape() { printf %s "$1" | sed \'s/\\\\/\\\\\\\\/g; s/"/\\\\"/g; s/[[:cntrl:]]/ /g\'; }', + spoolRecordLine, + ' chmod 600 "$spool_file" 2>/dev/null || :', + '}' + ] +} + export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin' // Why: qualify the stdin reader because Windows searches the worktree for // executables before PATH and hook payloads must not reach repo-local code. diff --git a/src/main/agent-hooks/server-relay-listener-replay.test.ts b/src/main/agent-hooks/server-relay-listener-replay.test.ts index 6c96d9c3bb8..0bd9b4b29ef 100644 --- a/src/main/agent-hooks/server-relay-listener-replay.test.ts +++ b/src/main/agent-hooks/server-relay-listener-replay.test.ts @@ -63,6 +63,7 @@ describe('AgentHookServer listener replay', () => { send(restartedRelay, { hook_event_name: 'SubagentStop', agent_id: 'child-a' }) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', + prompt: 'coordinate reviewers', model: 'gpt-5.4', providerSession: { key: 'session_id', id: 'root-session' }, subagents: [expect.objectContaining({ id: 'child-b' })] diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e447be40262..ba29d3bc211 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -116,6 +116,12 @@ import { type AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' +import { + buildSpoolHookBody, + drainAgentHookSpool, + launchTokenHash, + type SpoolRecord +} from '../../shared/agent-hook-spool' export type { AgentHookSource } @@ -801,6 +807,44 @@ export class AgentHookServer { } } + /** Replay is durable evidence from a prior runtime, not a live observation. */ + private withdrawReplayObservation(paneKey: string): void { + if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { + this.notifyStatusChangeListeners() + } + } + + private ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) + const normalized = this.normalizeLocalHookPayload(record.source, body) + if (!normalized.event) { + return + } + const replay = { ...normalized.event, isReplay: true as const } + const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { + source: record.source, + hookEventName: replay.hookEventName, + isReplay: true, + hasExplicitPrompt: replay.hasExplicitPrompt, + launchToken: replay.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay + if (statusDisposition === 'restart') { + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus(event, normalized.onAccepted) + if (event.payload.state !== 'done') { + this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) + } + } + setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { this.onPaneStatusCleared = listener } @@ -2259,14 +2303,14 @@ export class AgentHookServer { claudeRunningNonAgentTask?: unknown payload: unknown }, - connectionId: string + connectionId: string | null ): void { // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. - if (typeof connectionId !== 'string') { + if (connectionId !== null && typeof connectionId !== 'string') { return } - const trimmedConnectionId = connectionId.trim() - if (trimmedConnectionId.length === 0) { + const trimmedConnectionId = connectionId?.trim() ?? null + if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { return } if (!envelope || typeof envelope.paneKey !== 'string') { @@ -2286,6 +2330,14 @@ export class AgentHookServer { if (!parsedPaneKey) { return } + // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. + if (envelope.isReplay === true) { + const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) + if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { + return + } + } if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { return } @@ -2515,6 +2567,15 @@ export class AgentHookServer { this.hydrateLastStatusFromDisk() } this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { res.writeHead(404) @@ -3309,6 +3370,8 @@ export class AgentHookServer { restoredUnconfirmed: _restoredUnconfirmed, // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). observation: _observation, + // Replay provenance is runtime-only and must not survive another restart. + isReplay: _isReplay, launchToken, ...persistedPayload } = enrichedPayload diff --git a/src/main/agent-hooks/spool.test.ts b/src/main/agent-hooks/spool.test.ts new file mode 100644 index 00000000000..3e460fdc3ba --- /dev/null +++ b/src/main/agent-hooks/spool.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest' +import { + appendFileSync, + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + statSync, + writeFileSync +} from 'node:fs' +import { execFileSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + AGENT_HOOK_SPOOL_MAX_FILES, + drainAgentHookSpool, + launchTokenHash, + readSpoolRecords, + type SpoolRecord +} from '../../shared/agent-hook-spool' +import { AgentHookServer, _internals } from './server' +import { buildBody } from './server.test-fixtures' +import { _internals as codexInternals } from '../codex/hook-service' +import { makePaneKey } from '../../shared/stable-pane-id' +import { buildPosixHookSpoolLines } from './hook-stdin-contract' + +describe('agent hook spool', () => { + it('appends each record with one printf write to prevent concurrent field interleaving', () => { + const spoolLine = buildPosixHookSpoolLines('codex').find((line) => + line.includes('>> "$spool_file"') + ) + expect(spoolLine).toBeDefined() + expect(spoolLine!.match(/printf/g)).toHaveLength(1) + expect(spoolLine).toContain('"$spool_now" "$payload"') + }) + + it('drops torn lines while retaining complete records', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const file = join(dir, 'pane.jsonl') + writeFileSync( + file, + '\n{"paneKey":"tab:1","source":"codex","receivedAt":1,"payload":{}}\n{"paneKey":' + ) + expect(readSpoolRecords(file, 1)).toHaveLength(1) + }) + + it('waits for a newline before replaying a complete-looking final record', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-unterminated-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-live.jsonl') + const record = JSON.stringify({ + paneKey: 'tab:live', + source: 'codex', + receivedAt: Date.now(), + payload: { state: 'done' } + }) + writeFileSync(file, record) + const ingested: SpoolRecord[] = [] + const options = { + endpointDir: dir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (value: SpoolRecord) => ingested.push(value) + } + expect(readSpoolRecords(file)).toHaveLength(0) + expect(drainAgentHookSpool(options)).toBe(0) + expect(readFileSync(file, 'utf8')).toBe(record) + + appendFileSync(file, '\n') + expect(drainAgentHookSpool(options)).toBe(1) + expect(ingested).toHaveLength(1) + expect(readFileSync(file)).toHaveLength(0) + }) + + it('does not let historical empty pane files starve newer records', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-empty-files-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + for (let index = 0; index < AGENT_HOOK_SPOOL_MAX_FILES; index += 1) { + writeFileSync(join(spool, `pane-empty-${index}.jsonl`), '') + } + const live = join(spool, 'pane-live.jsonl') + writeFileSync( + live, + `\n${JSON.stringify({ paneKey: 'tab:live', source: 'codex', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const ingested: SpoolRecord[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(1) + expect(ingested[0]?.paneKey).toBe('tab:live') + }) + + it('rejects stale launch tokens before ingest and truncates in place', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-1.jsonl') + writeFileSync( + file, + `\n${JSON.stringify({ paneKey: 'tab:1', source: 'codex', launchToken: 'old', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const inode = statSync(file).ino + const ingested: unknown[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => launchTokenHash('new')!, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(0) + expect(readFileSync(file)).toHaveLength(0) + expect(statSync(file).ino).toBe(inode) + }) + + it('ingests a record with the matching launch token', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-1.jsonl') + writeFileSync( + file, + `\n${JSON.stringify({ paneKey: 'tab:1', source: 'codex', launchToken: 'same', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const ingested: unknown[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => launchTokenHash('same')!, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(1) + expect(ingested[0]).toMatchObject({ paneKey: 'tab:1', isReplay: true }) + }) + + it('replays a spooled Codex SubagentStop through the server after restart', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-e2e-')) + const paneKey = makePaneKey('tab-spool', '00000000-0000-4000-8000-000000000001') + const launchToken = 'generation-token' + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath }) + const started = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'child-spooled' }), + 'production' + )! + first.ingestRemote( + { + paneKey, + source: 'codex', + hookEventName: 'SubagentStart', + launchToken, + payload: started.payload + }, + 'spool-test' + ) + expect(first.getStatusSnapshot()).toHaveLength(1) + first.flushStatusPersistSync() + first.stop() + const spoolDir = join(userDataPath, 'agent-hooks', 'spool') + mkdirSync(spoolDir, { recursive: true }) + writeFileSync( + join(spoolDir, 'pane-tab-spooled_0.jsonl'), + `\n${JSON.stringify({ paneKey, source: 'codex', hookEventName: 'SubagentStop', launchToken, receivedAt: Date.now(), payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' } })}\n` + ) + const restarted = new AgentHookServer() + await restarted.start({ env: 'production', userDataPath }) + try { + const snapshot = restarted.getStatusSnapshot() + expect(snapshot).toHaveLength(1) + expect(snapshot[0]!.subagents).toBeUndefined() + restarted.flushStatusPersistSync() + expect(readFileSync(restarted.lastStatusPath!, 'utf8')).not.toContain('isReplay') + } finally { + restarted.stop() + } + }) + + it('rejects a stale remote replay against the hydrated launch-token fence', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-remote-fence-')) + const paneKey = makePaneKey('tab-remote-fence', '00000000-0000-4000-8000-000000000003') + const first = new AgentHookServer() + const second = new AgentHookServer() + try { + await first.start({ env: 'production', userDataPath }) + first.ingestRemote( + { + paneKey, + source: 'codex', + launchToken: 'old-generation', + payload: { state: 'working', agentType: 'codex', prompt: 'old' } + }, + 'ssh-1' + ) + first.flushStatusPersistSync() + first.stop() + + await second.start({ env: 'production', userDataPath }) + second.ingestRemote( + { + paneKey, + source: 'codex', + launchToken: 'new-generation', + isReplay: true, + payload: { state: 'done', agentType: 'codex', prompt: 'stale completion' } + }, + 'ssh-2' + ) + expect(second.getStatusSnapshot()[0]).toMatchObject({ + paneKey, + state: 'working', + prompt: 'old' + }) + } finally { + first.stop() + second.stop() + } + }) + + it('spools when the endpoint is present but the receiver is unavailable', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-failure-')) + const endpointDir = join(dir, 'agent-hooks') + mkdirSync(endpointDir, { recursive: true }) + const endpoint = join(endpointDir, 'endpoint.env') + writeFileSync( + endpoint, + 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\nORCA_AGENT_HOOK_ENV=production\nORCA_AGENT_HOOK_VERSION=1\n' + ) + const script = join(dir, 'codex-hook.sh') + writeFileSync(script, codexInternals.getManagedScript('posix')) + chmodSync(script, 0o755) + execFileSync('/bin/sh', [script], { + input: '{"hook_event_name":"SubagentStop","agent_id":"child"}\n', + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: endpoint, + ORCA_PANE_KEY: 'tab-failure:0', + ORCA_TAB_ID: 'tab-failure', + ORCA_AGENT_LAUNCH_TOKEN: 'generation-token' + }, + timeout: 5000 + }) + const spoolFiles = readdirSync(join(endpointDir, 'spool')) + expect(spoolFiles).toHaveLength(1) + expect(readFileSync(join(endpointDir, 'spool', spoolFiles[0]!), 'utf8')).toContain( + 'SubagentStop' + ) + }) + + it('does not mark a non-terminal downtime replay as runtime-observed', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-observed-')) + const paneKey = makePaneKey('tab-observed', '00000000-0000-4000-8000-000000000002') + const launchToken = 'observed-generation' + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath }) + const started = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'child-observed' }), + 'production' + )! + first.ingestRemote( + { + paneKey, + source: 'codex', + hookEventName: 'SubagentStart', + launchToken, + payload: started.payload + }, + null + ) + first.flushStatusPersistSync() + first.stop() + const spoolDir = join(userDataPath, 'agent-hooks', 'spool') + mkdirSync(spoolDir, { recursive: true }) + writeFileSync( + join(spoolDir, 'pane-observed.jsonl'), + `\n${JSON.stringify({ paneKey, source: 'codex', hookEventName: 'SubagentStart', launchToken, receivedAt: Date.now(), payload: started.payload })}\n` + ) + const restarted = new AgentHookServer() + await restarted.start({ env: 'production', userDataPath }) + try { + expect(restarted.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) + } finally { + restarted.stop() + } + }) +}) diff --git a/src/main/antigravity/hook-script.ts b/src/main/antigravity/hook-script.ts index 0645416c005..67f1f639ef9 100644 --- a/src/main/antigravity/hook-script.ts +++ b/src/main/antigravity/hook-script.ts @@ -1,5 +1,6 @@ import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_COMMAND @@ -55,10 +56,12 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: some Antigravity events arrive without stdin but still need a // status post, so the shared capture maps empty input to an object. ...buildPosixHookPayloadCapture('empty-object'), + ...buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -76,7 +79,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', ' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 4de81077ab2..3dba1121a20 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -17,6 +17,7 @@ import { import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_LABEL @@ -96,6 +97,7 @@ function getManagedScript( // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('claude'), ...(options.skipWhenDevinImportsClaude ? [ // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. @@ -115,6 +117,7 @@ function getManagedScript( ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: post form fields because path-bearing payloads are unsafe in hand-built JSON. @@ -129,7 +132,7 @@ function getManagedScript( ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 6dc78fdabf1..a25f14bcd89 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -31,6 +31,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, POSIX_HOOK_STDIN_DRAIN_COMMAND @@ -818,6 +819,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('codex'), // Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). 'load_hook_endpoint() {', ' endpoint_path="$1"', @@ -844,6 +846,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' load_hook_endpoint "$ORCA_AGENT_HOOK_ENDPOINT"', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', 'post_codex_hook() {', @@ -875,9 +878,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if is_wsl_runtime; then', ' windows_curl=$(command -v curl.exe 2>/dev/null || true)', ' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then', - ' post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', + ' if post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1; then', + ' exit 0', + ' fi', + ' # post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', ' fi', 'fi', + 'spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/command-code/command-code-managed-script.ts b/src/main/command-code/command-code-managed-script.ts index ca45f2fa579..59ea79b9d3a 100644 --- a/src/main/command-code/command-code-managed-script.ts +++ b/src/main/command-code/command-code-managed-script.ts @@ -1,6 +1,7 @@ import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -36,6 +37,7 @@ export function buildCommandCodeManagedScript( return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('command-code'), '__orca_read_ancestor_var() {', ' __orca_name="$1"', ' __orca_pid="${PPID:-}"', @@ -119,6 +121,7 @@ export function buildCommandCodeManagedScript( ' done', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -135,7 +138,7 @@ export function buildCommandCodeManagedScript( ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/copilot/copilot-managed-script.ts b/src/main/copilot/copilot-managed-script.ts index 83b40e51d1b..492caafc045 100644 --- a/src/main/copilot/copilot-managed-script.ts +++ b/src/main/copilot/copilot-managed-script.ts @@ -1,5 +1,8 @@ import { getSharedManagedScriptPath } from '../agent-hooks/installer-utils' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' export function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'copilot-hook.ps1' : 'copilot-hook.sh' @@ -53,12 +56,14 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { '#!/bin/sh', "printf '{}\\n'", ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('copilot'), // Why: Copilot consumes stdout for some hooks, so stdout is emitted before // endpoint refresh, stdin parsing, or the network POST can fail. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline @@ -75,7 +80,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index ab9d07b25eb..310afad939c 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -23,6 +23,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -76,11 +77,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('cursor'), // Why: refresh endpoint coordinates so surviving PTYs keep reporting. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON. @@ -95,7 +98,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 6c485b0daf5..a5e3667551e 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -13,6 +13,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -55,12 +56,14 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('devin'), // Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env). // Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race or CRLF-mangled line can't leak shell parse errors into agent transcripts (fail-open). 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: worktreeId embeds a filesystem path, so hand-building JSON in shell is unsafe (quotes/newlines); post as form fields instead. @@ -75,7 +78,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index fdef56a2401..3bdb715427f 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -24,6 +24,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -94,10 +95,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('droid'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -114,7 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 72ee056bd5f..6fd529e96e4 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -24,6 +24,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -72,11 +73,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: emit `{}` first so Gemini never stalls parsing stdout, even if the guards below exit early. 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('gemini'), // Why: source refreshes endpoint coords so a PTY surviving an Orca restart keeps reporting. See claude/hook-service.ts. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: worktreeId embeds a path, so post form fields, not hand-built JSON that breaks on quotes/newlines. @@ -91,7 +94,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/grok/grok-hook-script.ts b/src/main/grok/grok-hook-script.ts index cf4c57580b9..997bf7e64d4 100644 --- a/src/main/grok/grok-hook-script.ts +++ b/src/main/grok/grok-hook-script.ts @@ -3,7 +3,10 @@ import { wrapPosixHookCommand, wrapWindowsCmdHookCommand } from '../agent-hooks/installer-utils' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' import { buildWindowsGrokHookScript, GROK_HOME_ENVELOPE_MAX_LENGTH @@ -33,10 +36,12 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('grok'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', 'grok_home=', @@ -54,7 +59,7 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', ' --data-urlencode "grokHome=${grok_home}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index a7b954f01cb..e397fa0b026 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -24,7 +24,10 @@ import { writeManagedScriptRemote, writeTextFileRemoteAtomic } from '../agent-hooks/installer-utils-remote' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' import { applyManagedKimiHooks, KIMI_HOOK_EVENTS, @@ -71,14 +74,25 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + // Why: the windows-local ordering runs this guard before stdin is read and before + // spool_hook_event is defined, so only the payload-first ordering may spool here. + ...(windowsLocal ? [] : [' spool_hook_event']), ' exit 0', 'fi' ] return [ '#!/bin/sh', ...(windowsLocal - ? [...endpointRefreshAndGuard, ...buildPosixHookPayloadCapture()] - : [...buildPosixHookPayloadCapture(), ...endpointRefreshAndGuard]), + ? [ + ...endpointRefreshAndGuard, + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('kimi') + ] + : [ + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('kimi'), + ...endpointRefreshAndGuard + ]), // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. @@ -95,7 +109,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 0e3771aff55..e247706b5dc 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { RelayAgentHookServer } from './agent-hook-server' @@ -82,6 +82,79 @@ describe('RelayAgentHookServer', () => { } }) + it('normalizes and forwards raw spooled hooks on startup', async () => { + const spoolDir = join(dir, 'spool') + const spoolFile = join(spoolDir, 'pane-codex.jsonl') + mkdirSync(spoolDir) + writeFileSync( + spoolFile, + `${JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'remote', + version: '1', + launchToken: 'generation-token', + hookEventName: 'SubagentStop', + source: 'codex', + payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' }, + receivedAt: Date.now() + })}\n` + ) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + + await server.start() + try { + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0]).toMatchObject({ + source: 'codex', + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + launchToken: 'generation-token', + hookEventName: 'SubagentStop', + isReplay: true, + env: 'remote', + version: '1', + payload: { state: 'working', agentType: 'codex' } + }) + expect(readFileSync(spoolFile)).toHaveLength(0) + } finally { + server.stop() + } + }) + + it('keeps the relay listening when spool replay forwarding fails', async () => { + const spoolDir = join(dir, 'spool') + const spoolFile = join(spoolDir, 'pane-codex.jsonl') + mkdirSync(spoolDir) + writeFileSync( + spoolFile, + `${JSON.stringify({ + paneKey: PANE_KEY, + source: 'codex', + hookEventName: 'SubagentStop', + payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' }, + receivedAt: Date.now() + })}\n` + ) + const server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + throw new Error('receiver unavailable') + } + }) + + try { + await expect(server.start()).resolves.toBeUndefined() + expect(server.getCoordinates().port).toBeGreaterThan(0) + expect(readFileSync(spoolFile, 'utf8')).not.toBe('') + } finally { + server.stop() + } + }) + it('caches before forwarding, schedules retries after forwarding, and responds last', async () => { const order: string[] = [] let server!: RelayAgentHookServer diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 84474cbab00..ec980470dd4 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -24,10 +24,16 @@ import { isHookRequestTruncatedError } from '../shared/agent-hook-transport-interference' import { + isAgentHookSource, REMOTE_AGENT_HOOK_ENV, type AgentHookRelayEnvelope, type AgentHookSource } from '../shared/agent-hook-relay' +import { + buildSpoolHookBody, + drainAgentHookSpool, + type SpoolRecord +} from '../shared/agent-hook-spool' import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates' import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build' import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' @@ -100,6 +106,19 @@ export class RelayAgentHookServer { this.token = this.fixedToken ?? randomUUID() this.endpointFileWritten = false this.portFallbackApplied = false + try { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (record) => this.ingestSpoolRecord(record) + }) + } catch (err) { + // Why: a downstream relay failure must not prevent the loopback listener from starting; + // the untruncated spool file remains available for retry on the next restart. + process.stderr.write( + `[relay-hook-server] spool replay failed: ${err instanceof Error ? err.message : String(err)}\n` + ) + } try { await this.listenOn(this.preferredPort) } catch (err) { @@ -273,7 +292,8 @@ export class RelayAgentHookServer { event: AgentHookEventPayload, source: AgentHookSource, env?: string, - version?: string + version?: string, + options: { isReplay?: boolean } = {} ): void { if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.retryScheduler.clearAssistantMessageRetry(event.paneKey) @@ -294,6 +314,22 @@ export class RelayAgentHookServer { } this.clearPaneState(oldest) } - this.forward(buildRelayHookEnvelope(event, source, env, version)) + this.forward(buildRelayHookEnvelope(event, source, env, version, options)) + } + + private ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = buildSpoolHookBody(record) + const event = normalizeHookPayload(this.state, record.source, body, this.env, { + deferCompactOwnershipToClient: true + }) + if (!event) { + return + } + this.applyEvent(event, record.source, hookBodyEnv(body), hookBodyVersion(body), { + isReplay: true + }) } } diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index c14c1d8a2b1..1131008ca7a 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -137,8 +137,15 @@ export function reconcileRemoteCodexState( if (!lead) { return payload } + // Child lifecycle hooks commonly omit the root prompt. Preserve the last known + // turn label while merging their roster/state so relay restarts do not blank it. + const prompt = + agentId && payload.prompt.length === 0 && previous?.agentType === 'codex' + ? previous.prompt + : payload.prompt return { ...payload, + prompt, state: codexRosterEffectiveState(roster, lead.state), model: lead.model ?? payload.model, subagents: codexRosterToSnapshots(roster) diff --git a/src/shared/agent-hook-spool.ts b/src/shared/agent-hook-spool.ts new file mode 100644 index 00000000000..3ad478ed8a5 --- /dev/null +++ b/src/shared/agent-hook-spool.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { + closeSync, + fstatSync, + ftruncateSync, + openSync, + readFileSync, + readSync, + readdirSync, + statSync, + writeSync +} from 'node:fs' +import { join } from 'node:path' + +export const AGENT_HOOK_SPOOL_MAX_BYTES = 5 * 1024 * 1024 +export const AGENT_HOOK_SPOOL_MAX_FILES = 1024 +export const AGENT_HOOK_SPOOL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +export type SpoolRecord = { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + launchToken?: string + hookEventName?: string + source: string + payload: unknown + receivedAt: number + [key: string]: unknown +} + +/** Restore the HTTP listener body shape from a shell-written spool record. */ +export function buildSpoolHookBody(record: SpoolRecord): Record { + return { + paneKey: record.paneKey, + tabId: record.tabId, + worktreeId: record.worktreeId, + env: record.env, + version: record.version, + launchToken: record.launchToken, + hookEventName: record.hookEventName, + payload: record.payload + } +} + +export function launchTokenHash(token: string | undefined): string | null { + return token?.trim() ? createHash('sha256').update(token.trim()).digest('hex') : null +} + +export function readSpoolRecords(path: string, now = Date.now()): SpoolRecord[] { + return readSpoolFile(path, now).records +} + +/** Records plus the byte offset through the last COMPLETE line. A torn trailing line is + * left unconsumed so a writer still finishing it is not truncated away. */ +export function readSpoolFile( + path: string, + now = Date.now() +): { records: SpoolRecord[]; consumed: number } { + let bytes: Buffer + try { + bytes = readFileSync(path) + } catch { + return { records: [], consumed: 0 } + } + const records: SpoolRecord[] = [] + let consumed = 0 + let start = 0 + for (let end = 0; end <= bytes.length; end += 1) { + if (end !== bytes.length && bytes[end] !== 0x0a) { + continue + } + // A final line without its newline may still be in flight from a hook writer. + // Leave it untouched until the writer terminates the record explicitly. + if (end === bytes.length && (end === 0 || bytes[end - 1] !== 0x0a)) { + break + } + const lineBytes = bytes.subarray(start, end) + if (end !== bytes.length) { + consumed = end + 1 + } + start = end + 1 + if (lineBytes.length === 0) { + continue + } + try { + const value = JSON.parse(lineBytes.toString('utf8')) as Partial + if ( + typeof value.paneKey === 'string' && + typeof value.source === 'string' && + value.payload !== undefined && + typeof value.receivedAt === 'number' && + value.receivedAt >= now - AGENT_HOOK_SPOOL_MAX_AGE_MS + ) { + records.push(value as SpoolRecord) + } + } catch { + // Torn lines are discarded while later complete lines remain replayable. + } + } + return { records, consumed } +} + +export type SpoolDrainOptions = { + endpointDir: string + getPersistedLaunchTokenHash: (paneKey: string) => string | undefined + ingest: (record: SpoolRecord) => void + now?: number +} + +/** Drain JSONL files in place; never replace or unlink an inode held by a hook writer. */ +export function drainAgentHookSpool(options: SpoolDrainOptions): number { + const spoolDir = join(options.endpointDir, 'spool') + let names: string[] + try { + names = readdirSync(spoolDir) + } catch { + return 0 + } + const now = options.now ?? Date.now() + const candidates = names + .map((name) => { + const path = join(spoolDir, name) + try { + const stat = statSync(path) + // Empty files are retained to keep append handles race-safe, but they must not + // consume the bounded replay candidate set ahead of files with durable events. + return stat.isFile() && stat.size > 0 ? { path, mtimeMs: stat.mtimeMs } : null + } catch { + return null + } + }) + .filter((entry): entry is { path: string; mtimeMs: number } => entry !== null) + .sort((a, b) => a.mtimeMs - b.mtimeMs) + .slice(0, AGENT_HOOK_SPOOL_MAX_FILES) + let drained = 0 + for (const candidate of candidates) { + const { records, consumed } = readSpoolFile(candidate.path, now) + for (const record of records) { + const expected = options.getPersistedLaunchTokenHash(record.paneKey) + const actual = launchTokenHash(record.launchToken) + if (expected && actual !== expected) { + continue + } + options.ingest({ ...record, isReplay: true }) + drained += 1 + } + try { + const fd = openSync(candidate.path, 'r+') + try { + // Keep the inode so a concurrent append handle cannot silently orphan writes. + const size = fstatSync(fd).size + if (size > consumed) { + // Why: a hook may have appended between the read and here; shift the unread tail + // to the front instead of truncating to zero, which would erase it. + const tail = Buffer.alloc(size - consumed) + readSync(fd, tail, 0, tail.length, consumed) + writeSync(fd, tail, 0, tail.length, 0) + ftruncateSync(fd, tail.length) + } else { + ftruncateSync(fd, 0) + } + } finally { + closeSync(fd) + } + } catch { + // A concurrently removed or inaccessible file is harmless; the next launch retries it. + } + } + return drained +} From 8d0156ae0b225fe0d5e489c5eac1fd8063efb950 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:30:30 -0700 Subject: [PATCH 013/118] fix(terminal): route Enter to focused split pane (#16705) * fix(terminal): route shortcuts to focused split pane * fix(terminal): synchronize IME fallback pane --- ...keyboard-handlers-ime-enter-keyup.test.tsx | 51 +++++++++- ...inal-keyboard-event-handlers-focus.test.ts | 94 +++++++++++++++++++ .../terminal-keyboard-event-handlers.ts | 14 ++- .../terminal-keyboard-pane-resolution.test.ts | 64 +++++++++++++ .../terminal-keyboard-pane-resolution.ts | 28 ++++++ .../terminal-keyboard-release-handlers.ts | 3 +- 6 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx index 2d4f179c753..88396a6be87 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx @@ -28,9 +28,10 @@ function keyboardEvent( return event } -function createHarness(): { +function createHarness(options: { staleActivePane?: boolean } = {}): { deps: KeyboardHandlersDeps sendInput: ReturnType + setActivePane: ReturnType startComposition: () => void terminalInput: HTMLTextAreaElement dispose: () => void @@ -40,7 +41,8 @@ function createHarness(): { const terminalInput = document.createElement('textarea') terminalInput.className = 'xterm-helper-textarea' terminalElement.append(terminalInput) - scope.append(terminalElement) + const staleTerminalElement = document.createElement('div') + scope.append(staleTerminalElement, terminalElement) document.body.append(scope) const sendInput = vi.fn(() => true) @@ -49,7 +51,7 @@ function createHarness(): { sendInput } as unknown as PtyTransport const pane = { - id: 1, + id: options.staleActivePane ? 2 : 1, leafId: '00000000-0000-4000-8000-000000000001', terminal: { element: terminalElement, @@ -57,9 +59,24 @@ function createHarness(): { getSelection: vi.fn(() => '') } } + const stalePane = { + id: 1, + leafId: '00000000-0000-4000-8000-000000000002', + terminal: { + element: staleTerminalElement, + focus: vi.fn(), + getSelection: vi.fn(() => '') + } + } + let activePane = options.staleActivePane ? stalePane : pane + const panes = options.staleActivePane ? [stalePane, pane] : [pane] + const setActivePane = vi.fn((paneId: number) => { + activePane = panes.find((candidate) => candidate.id === paneId) ?? activePane + }) const manager = { - getActivePane: () => pane, - getPanes: () => [pane] + getActivePane: () => activePane, + getPanes: () => panes, + setActivePane } as unknown as PaneManager const route = installTerminalImeCompositionRoute({ terminalElement, @@ -96,6 +113,7 @@ function createHarness(): { return { deps, sendInput, + setActivePane, terminalInput, startComposition: () => { terminalElement.dispatchEvent( @@ -337,6 +355,29 @@ describe('Windows IME Enter-keyup press-time evidence', () => { harness.dispose() }) + it('routes a swallowed Enter keydown fallback to the focused pane and repairs stale active state', () => { + const harness = createHarness({ staleActivePane: true }) + const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps)) + harness.startComposition() + + harness.terminalInput.dispatchEvent( + keyboardEvent('keyup', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + timeStamp: 30, + shiftKey: true + }) + ) + vi.runAllTimers() + + expect(harness.setActivePane).toHaveBeenCalledWith(2, { focus: false }) + expect(harness.sendInput).toHaveBeenCalledOnce() + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + hook.unmount() + harness.dispose() + }) + it('does not send a second newline from the keyup of a directly-sent Shift+Enter', () => { const harness = createHarness() const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps)) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts new file mode 100644 index 00000000000..bc1e883647b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { createTerminalKeyboardEventHandlers } from './terminal-keyboard-event-handlers' + +describe('terminal keyboard pane ownership', () => { + it('submits Enter through the helper textarea pane when active state is stale', () => { + const scope = document.createElement('div') + const firstElement = document.createElement('div') + const focusedElement = document.createElement('div') + const focusedInput = document.createElement('textarea') + focusedInput.className = 'xterm-helper-textarea' + focusedElement.appendChild(focusedInput) + scope.append(firstElement, focusedElement) + document.body.appendChild(scope) + + const first = { id: 1, leafId: 'leaf-1', terminal: { element: firstElement } } + const focused = { id: 2, leafId: 'leaf-2', terminal: { element: focusedElement } } + let active = first + const setActivePane = vi.fn((paneId: number) => { + active = paneId === focused.id ? focused : first + }) + const sendFocused = vi.fn() + const handlers = createTerminalKeyboardEventHandlers({ + isMac: false, + isWindows: false, + shortcutPlatform: 'linux', + keyboardScopeRef: { current: scope }, + resolveShortcutEvent: () => ({ type: 'sendInput', data: '\r' }), + createCapturedInputSender: (pane) => (pane.id === focused.id ? sendFocused : vi.fn()), + nativeOnlyShortcutTracker: { + prepareKeyDown: vi.fn(), + armKeyDown: vi.fn() + }, + observedEnterKeydownTimeStamps: new Map(), + modifiedEnterChordOwner: { + ownsRedispatchedEnter: () => false, + absorb: () => false, + claim: () => true + }, + deferredNewlineSender: { + absorbRedispatchedEnter: () => false, + defer: vi.fn() + }, + deferredChordSender: { defer: vi.fn() }, + getModifiedEnterChord: () => null, + reconcileHeldImeEnterModifiers: vi.fn(), + optionKittyReleases: { arm: vi.fn(), armNativeDeadKey: vi.fn() }, + terminalImeEnterModifierKeydowns: new Set(), + paneKittyKeyboardModesRef: { current: new Map() }, + managerRef: { + current: { + getActivePane: () => active, + getPanes: () => [first, focused], + setActivePane + } + }, + paneTransportsRef: { current: new Map() }, + panePtyBindingsRef: { current: new Map() }, + paneCwdRef: { current: new Map() }, + tabId: 'tab-1', + worktreeId: 'worktree-1', + fallbackCwd: '', + expandedPaneIdRef: { current: null }, + setExpandedPane: vi.fn(), + restoreExpandedLayout: vi.fn(), + refreshPaneSizes: vi.fn(), + persistLayoutSnapshot: vi.fn(), + toggleExpandPane: vi.fn(), + setSearchOpen: vi.fn(), + onSearchSelectedText: vi.fn(), + onRequestClosePane: vi.fn(), + onClearPaneScrollback: vi.fn(), + onSetTitle: vi.fn(), + onClearPaneTitle: vi.fn(), + searchOpenRef: { current: false }, + searchStateRef: { current: { query: '', caseSensitive: false, regex: false } }, + keybindings: undefined, + terminalShortcutPolicy: 'orca-first', + getKeyboardSplitTelemetrySource: () => 'keyboard' + } as never) + + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Enter' + }) + Object.defineProperty(event, 'keyCode', { value: 13 }) + focusedInput.dispatchEvent(event) + handlers.onKeyDown(event) + + expect(setActivePane).toHaveBeenCalledWith(2, { focus: false }) + expect(sendFocused).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts index c711ff747a4..a66fbf96fe4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts @@ -18,6 +18,7 @@ import { import { dispatchTerminalShortcutAction } from './terminal-keyboard-action-dispatch' import { getLayoutCharacterForCode } from '@/lib/keyboard-layout/layout-base-character' import { createTerminalKeyboardReleaseHandlers } from './terminal-keyboard-release-handlers' +import { synchronizeTerminalKeyboardPane } from './terminal-keyboard-pane-resolution' const MAX_OBSERVED_ENTER_KEYDOWNS_PER_CODE = 8 @@ -111,6 +112,11 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { return } + // The browser's focused xterm helper is the input owner. A split can retain + // a stale manager activePaneId after focus moved, so repair it before any + // shortcut policy reads pane-scoped host/agent state. + const keyboardPane = synchronizeTerminalKeyboardPane(manager, e.target) + const modifiedEnterChord = isWindows ? getModifiedEnterChord(e) : null if ( e.key === 'Enter' && @@ -127,7 +133,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { return } - const terminalPaneForImeShortcut = manager.getActivePane() ?? manager.getPanes()[0] + const terminalPaneForImeShortcut = keyboardPane const hasPendingImeComposition = hasPendingTerminalImeComposition( terminalPaneForImeShortcut?.terminal.element ) @@ -139,7 +145,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { } if (matchFileSearchShortcut(e, shortcutPlatform, keybindings, terminalShortcutPolicy)) { - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = keyboardPane const selectedText = normalizeSelectedTextForFileSearch(pane?.terminal.getSelection()) if (selectedText) { e.preventDefault() @@ -160,7 +166,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { } e.preventDefault() e.stopImmediatePropagation() - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = keyboardPane if (!pane) { return } @@ -210,7 +216,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { if (action.type === 'sendInput') { e.preventDefault() e.stopImmediatePropagation() - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = keyboardPane if (!pane) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts new file mode 100644 index 00000000000..3876f2099e0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { + resolveTerminalKeyboardPane, + synchronizeTerminalKeyboardPane +} from './terminal-keyboard-pane-resolution' + +function pane(id: number): { + id: number + leafId: string + terminal: { element: HTMLElement } +} { + return { + id, + leafId: `leaf-${id}`, + terminal: { element: document.createElement('div') } + } +} + +describe('resolveTerminalKeyboardPane', () => { + it('uses the pane containing the event target when manager active state is stale', () => { + const active = pane(1) + const focused = pane(2) + const input = document.createElement('textarea') + focused.terminal.element.appendChild(input) + const manager = { + getActivePane: vi.fn(() => active), + getPanes: vi.fn(() => [active, focused]) + } + + expect(resolveTerminalKeyboardPane(manager as never, input)).toBe(focused) + expect(manager.getActivePane).not.toHaveBeenCalled() + }) + + it('falls back to the manager active pane for non-terminal shortcuts', () => { + const active = pane(1) + const manager = { + getActivePane: vi.fn(() => active), + getPanes: vi.fn(() => [active]) + } + + expect(resolveTerminalKeyboardPane(manager as never, document.body)).toBe(active) + }) + + it('repairs stale active state without stealing DOM focus', () => { + const active = pane(1) + const focused = pane(2) + const input = document.createElement('textarea') + focused.terminal.element.appendChild(input) + let activePane = active + const setActivePane = vi.fn((id: number) => { + activePane = id === focused.id ? focused : active + }) + const manager = { + getActivePane: vi.fn(() => activePane), + getPanes: vi.fn(() => [active, focused]), + setActivePane + } + + expect(synchronizeTerminalKeyboardPane(manager as never, input)).toBe(focused) + expect(setActivePane).toHaveBeenCalledWith(focused.id, { focus: false }) + expect(activePane).toBe(focused) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts new file mode 100644 index 00000000000..643b0439741 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts @@ -0,0 +1,28 @@ +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' + +/** Resolve keyboard ownership from the focused xterm surface before using manager state. */ +export function resolveTerminalKeyboardPane( + manager: Pick, + target: EventTarget | null +): ManagedPane | null { + if (typeof Node !== 'undefined' && target instanceof Node) { + const focusedPane = manager + .getPanes() + .find((pane) => pane.terminal.element?.contains(target) === true) + if (focusedPane) { + return focusedPane + } + } + return manager.getActivePane() ?? manager.getPanes()[0] ?? null +} + +export function synchronizeTerminalKeyboardPane( + manager: Pick, + target: EventTarget | null +): ManagedPane | null { + const pane = resolveTerminalKeyboardPane(manager, target) + if (pane && manager.getActivePane()?.id !== pane.id) { + manager.setActivePane(pane.id, { focus: false }) + } + return pane +} diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts index 2c96347ad00..095c1e33b77 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts @@ -4,6 +4,7 @@ import { isTerminalImeEnterKeyUp } from './terminal-ime-deferred-newline' import { isEditableTarget } from './terminal-keyboard-shortcut-matching' import { hasPendingTerminalImeComposition } from './terminal-ime-composition-route' import { keyboardEventBelongsToScope } from './terminal-keyboard-scope' +import { synchronizeTerminalKeyboardPane } from './terminal-keyboard-pane-resolution' type Runtime = ReturnType type ReleaseContext = KeyboardHandlersDeps & @@ -101,7 +102,7 @@ export function createTerminalKeyboardReleaseHandlers(context: ReleaseContextWit !isEditableTarget(e.target) && (!scope || keyboardEventBelongsToScope(e, scope)) ) { - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = synchronizeTerminalKeyboardPane(manager, e.target) if (pane && hasPendingTerminalImeComposition(pane.terminal.element)) { const action = resolveShortcutEvent({ key: 'Enter', From de6fe8b7eabd220d100e4cc652413461d444d2ba Mon Sep 17 00:00:00 2001 From: Lesley Murfin Date: Thu, 27 Aug 2026 00:35:18 -0600 Subject: [PATCH 014/118] fix(worktrees): resolve id: worktree selectors by path equivalence (#16243) (#16494) * test(worktrees): cover id: selector path-spelling parity with path: (#16243) The renderer can only address a workspace by id (toRuntimeWorktreeSelector always emits id:::), and the runtime matches that id byte for byte while a path: selector has always compared through normalizeRuntimePathForComparison. A stored id that spells its path differently from `git worktree list` therefore resolves for the CLI and answers selector_not_found for the UI, which reads that as a stale local mirror, calls forgetLocal, reports success, and lets the row return on the next catalog refresh: a silent delete. These tests fail on both resolution sites -- the fleet `id:` branch of resolveWorktreeSelector and the scoped resolveScopedWorktreeIdRow a host-qualified removal takes -- and pin what must stay closed: an exact repo id (STA-4343), host qualification, dot segments neither selector canonicalizes, and a refusal rather than a guess when two rows spell one path. 13 failing, 35 passing. * fix(worktrees): resolve id: worktree selectors by path equivalence (#16243) worktreeIdComparisonKey names one repo, one filesystem location, and one folder-workspace instance, folding exactly the path spellings normalizeRuntimePathForComparison already folds for a path: selector -- and nothing more, so dot segments stay unresolved for both shapes. Both id: resolution sites consult it only after an exact match finds nothing: the fleet branch of resolveWorktreeSelector and resolveScopedWorktreeIdRow, which a host-qualified removal takes. runtimeWorktreeIdsEqual now derives from the same key so the runtime has one normalizer rather than a parallel one. Not a pure refactor at that last site: runtimeWorktreeIdsEqual used to normalize-compare ids that parse but carry an empty repoId or an empty path ('::/p', or 'repo::' against 'repo::/'), and worktreeIdComparisonKey returns null for those, so across its call sites (PTY identity, refresh, mutation queue) such ids now compare byte-exact instead. That narrows matching rather than widening it, no real worktree carries such an id, and it is the behavior #15616 guarantees for malformed ids -- but it is a behavior delta, not just a tidy-up. Perf (#14399): the exact match is still tried first and still wins outright, so a resolvable id costs exactly what it did before. Neither site adds a scan -- the fleet branch re-filters the array it had already listed, the scoped lookup re-filters the single owning repo's projected rows -- so an explicit id still never scans every repo. Fail-closed behavior is unchanged: the repo id compares exactly (STA-4343), host qualification is untouched, the folder-workspace instance suffix stays part of the path, and a scoped lookup with two equivalent rows refuses instead of guessing. The bare unprefixed selector branch keeps byte-exact id matching, since only the id: shape reaches a renderer caller. Shares src/shared/worktree/id.ts with the open #15616, which introduces worktreeIdComparisonKey for the same divergence in lineage pruning and authoritative-scan purging; this adopts that helper rather than adding a second one. Complementary to the open #16295, which makes the miss visible; this removes the miss. * chore(worktrees): satisfy oxfmt and oxlint on #16243 tests oxfmt --check flagged both new test files and oxlint's unicorn/no-useless-fallback-in-spread flagged the store mock; the full lint and format gates now match the pre-change baseline. * test(worktrees): pin Windows spellings and malformed-id exactness (#16243) Review found two axes the first pass left unproven at the two id: resolution sites. Both are the invariants the open #15616 guarantees for the shared worktreeIdComparisonKey it introduces for #15598, so violating either here would break a contract a sibling PR depends on. Windows: #15598's whole defect is that one checkout is recorded under both `D:\Agentic\game2` and `D:/Agentic/game2`. The fleet branch, the scoped removal lookup, and the key itself now each resolve the backslash spelling against the forward-slash spelling git reports, and fold drive-letter case -- while a backslash inside a POSIX path stays a filename character and a POSIX root stays case-sensitive, exactly as normalizeRuntimePathForComparison already decides for a path: selector. Malformed ids keep exact matching at both sites: an id with no repo boundary or an empty path still refuses, and the scoped lookup still refuses it without scanning. Four of these fail without the production change (three fleet/removal Windows cases and the scoped one); the malformed-id and POSIX-backslash cases are invariant guards that hold either way. Verified: 58 passed in the three files; 18 fail with the production hunks reverted; orca-runtime.test.ts and worktree-teardown-unstopped-pty.test.ts green (1270 passed | 1 skipped); pnpm tc:node clean. * test(worktrees): pin Windows id: spelling folds and fleet ambiguity refusal (#16243) The Windows backslash spelling now rides the ID_SPELLINGS rows, so it is driven through both id: sites -- resolveWorktreeSelector and the scoped removal target -- and compared against what the same workspace's path: selector resolves, rather than only through worktreeIdComparisonKey. That is the spelling #15598/#15616 found in the wild and the one the owner's Windows client produces. The fleet path's ambiguity refusal had no test: two same-repo rows spelling one directory, an id: matching neither exactly, must reject selector_ambiguous. It is the fail-closed guard on a delete-capable resolver, and the property a later refactor is most likely to turn into a silent pick. Also records two limits at the source instead of leaving them to be rediscovered: a UNC or WSL root never folds into a drive-letter location (while Windows' two WSL UNC aliases do name one location), and a folder-workspace id keeps a trailing slash placed before the ::workspace: suffix, so that spelling stays exact-match-only. Neither behavior changes here. The file docblock overclaimed parity. path: collapses duplicate same-host registrations to the first row while a folded id: refuses them; the contract this file pins is path-spelling parity, not dedup parity, and the divergence is deliberate because this resolver also serves delete. Non-vacuity, verified by temporarily reverting the production hunks: neutralizing both id: fallbacks turns 10 of these tests red, including both new Windows rows and the ambiguity refusal (it degrades to selector_not_found). Making the fleet fallback pick the first folded match instead of collecting all of them turns the ambiguity test red on its own. The remaining cases -- malformed ids, dot segments, the POSIX backslash, the folder-workspace slash -- pass against the pre-fix code too: they guard against future widening rather than proving this fix. Drops the two Windows cases the ID_SPELLINGS row subsumes. The drive-letter case test asserted only the Windows half its name promised; it now also pins that a POSIX root does NOT fold case, since an unconditional lowercase would merge /data/Foo with /data/foo on the platform CI runs on. Fixture paths use the upstream-attested /srv/projects prefix (and a neutral plugin-host leaf) instead of a local install root; the spelling variations the tests exist to pin -- doubled separator, dot segment, trailing slash, uppercase POSIX, cafe NFC/NFD, and the Windows D: rows -- are unchanged in form. * docs(worktrees): trim the id: selector test header and document the comparison key (#16243) --- src/main/runtime/orca-runtime.ts | 22 +- .../repo-worktree-row-resolution.test.ts | 85 ++++++ .../runtime/repo-worktree-row-resolution.ts | 21 +- ...ktree-rm-id-selector-path-spelling.test.ts | 273 ++++++++++++++++++ src/shared/worktree/id.test.ts | 65 ++++- src/shared/worktree/id.ts | 17 ++ 6 files changed, 473 insertions(+), 10 deletions(-) create mode 100644 src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c33d741bc29..ee1f977ca88 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -534,7 +534,8 @@ import { WORKTREE_ID_SEPARATOR, getRepoIdFromWorktreeId, splitWorktreeId, - splitWorktreeIdForFilesystem + splitWorktreeIdForFilesystem, + worktreeIdComparisonKey } from '../../shared/worktree/id' import { getProjectIdForProviderIdentity, @@ -32294,6 +32295,7 @@ export class OrcaRuntimeService { return worktreeId } + /** Resolves one workspace or throws `selector_not_found` / `selector_ambiguous` — never picks a winner. */ private async resolveWorktreeSelector(selector: string): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) // Why only `id:`: every other selector kind is matched across the whole fleet, and their @@ -32315,6 +32317,16 @@ export class OrcaRuntimeService { if (selector.startsWith('id:')) { const worktreeId = explicitWorktreeId ?? selector.slice(3) candidates = worktrees.filter((worktree) => worktree.id === worktreeId) + if (candidates.length === 0) { + // Why (#16243): `id:` is the only shape the renderer can send, and a stored id can spell + // its path differently from the scan — the divergence `path:` has always absorbed. + // The bare unprefixed branch below stays byte-exact on purpose: only `id:` reaches a + // renderer caller, so `id:repo::p/` folds here while bare `repo::p/` still misses. + const comparisonKey = worktreeIdComparisonKey(worktreeId) + candidates = comparisonKey + ? worktrees.filter((worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey) + : candidates + } if (candidates.length === 0) { const parsed = splitWorktreeIdForFilesystem(worktreeId) const repo = parsed ? this.store?.getRepo(parsed.repoId) : null @@ -41428,12 +41440,8 @@ function runtimePathsEqual(left: string, right: string): boolean { * Windows/WSL/SSH ids still match themselves across hosts. */ function runtimeWorktreeIdsEqual(left: string, right: string): boolean { - const parsedLeft = splitWorktreeId(left) - const parsedRight = splitWorktreeId(right) - return parsedLeft && parsedRight - ? parsedLeft.repoId === parsedRight.repoId && - runtimePathsEqual(parsedLeft.worktreePath, parsedRight.worktreePath) - : left === right + const leftKey = worktreeIdComparisonKey(left) + return leftKey === null ? left === right : leftKey === worktreeIdComparisonKey(right) } function runtimeWorktreeIdentityKey(worktreeId: string): string { diff --git a/src/main/runtime/repo-worktree-row-resolution.test.ts b/src/main/runtime/repo-worktree-row-resolution.test.ts index be50f5678a7..d4bff4a9442 100644 --- a/src/main/runtime/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/repo-worktree-row-resolution.test.ts @@ -200,3 +200,88 @@ describe('host-qualified scoped worktree resolution', () => { expect(getRepos).not.toHaveBeenCalled() }) }) + +/** + * #16243: the renderer can only address a workspace by `id:::`, and this scoped + * lookup is what a host-qualified removal resolves through. It matched the id byte for byte while a + * `path:` selector has always compared through `normalizeRuntimePathForComparison`, so a stored id + * spelling its path differently from `git worktree list` resolved for the CLI and not for the UI. + */ +describe('scoped worktree id resolution across path spellings (#16243)', () => { + it.each([ + ['a trailing slash', '/same/worktree', 'shared::/same/worktree/'], + ['a doubled separator', '/same/worktree', 'shared::/same//worktree'], + ['an NFD name', '/same/café', `shared::${'/same/café'.normalize('NFD')}`] + ])('resolves the scanned row when the id carries %s', async (_label, scannedPath, worktreeId) => { + const owner = repo('shared', '/local/repo', { executionHostId: 'local' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ + ok: true, + worktrees: [gitWorktree(scannedPath)] + })) + + await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toMatchObject({ + id: `shared::${scannedPath}`, + path: scannedPath + }) + }) + + it('still refuses the same path under a different repo id', async () => { + const deps = createDeps([ + repo('shared', '/local/repo', { executionHostId: 'local' }), + repo('unrelated', '/unrelated/repo', { executionHostId: 'local' }) + ]) + + await expect( + resolveScopedWorktreeIdRow(deps, 'unrelated::/same/worktree/', 'local') + ).resolves.toBeNull() + }) + + it('refuses rather than guessing when two rows spell one path', async () => { + const owner = repo('shared', '/local/repo', { executionHostId: 'local' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ + ok: true, + worktrees: [gitWorktree('/same/worktree'), gitWorktree('/same//worktree')] + })) + + await expect( + resolveScopedWorktreeIdRow(deps, 'shared::/same/worktree/', 'local') + ).resolves.toBeNull() + }) + + it('prefers the exactly matching row over an equivalent spelling', async () => { + const owner = repo('shared', '/local/repo', { executionHostId: 'local' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ + ok: true, + worktrees: [gitWorktree('/same//worktree'), gitWorktree('/same/worktree')] + })) + + await expect( + resolveScopedWorktreeIdRow(deps, 'shared::/same//worktree', 'local') + ).resolves.toMatchObject({ id: 'shared::/same//worktree' }) + }) + + // #15598/#15616: the backslash spelling is what a pre-restart Windows registration recorded. + it('resolves a Windows backslash id against the forward-slash spelling git reports', async () => { + const path = 'D:/Agentic/game2/battle-core' + const owner = repo('shared', 'D:/Agentic/game2', { executionHostId: 'runtime:windows' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ ok: true, worktrees: [gitWorktree(path)] })) + + await expect( + resolveScopedWorktreeIdRow(deps, 'shared::D:\\Agentic\\game2\\battle-core', 'runtime:windows') + ).resolves.toMatchObject({ id: `shared::${path}`, path }) + }) + + it.each([ + ['no repo boundary', 'not-an-id'], + ['an empty path', 'shared::'] + ])('keeps exact matching for a malformed id with %s', async (_label, worktreeId) => { + const deps = createDeps([repo('shared', '/local/repo', { executionHostId: 'local' })]) + + await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toBeNull() + expect(deps.scanRepo).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index b65fd9295f6..a9ef618ced7 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -1,4 +1,8 @@ -import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { + splitWorktreeId, + splitWorktreeIdForFilesystem, + worktreeIdComparisonKey +} from '../../shared/worktree/id' import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { isFolderRepo } from '../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' @@ -195,5 +199,18 @@ export async function resolveScopedWorktreeIdRow( resolveLocalProjectRuntimesForRepos(store, [repo]) ) const projected = projectResolvedWorktreeLineage(rows, store.getAllWorktreeLineage?.() ?? {}) - return projected.find((worktree) => worktree.id === worktreeId) ?? null + const exact = projected.find((worktree) => worktree.id === worktreeId) + if (exact) { + return exact + } + // Why (#16243): the scan can spell this id's path differently — the divergence `path:` absorbs. + // One equivalent row may stand in; two is an ambiguity a scoped lookup must refuse, not guess. + const comparisonKey = worktreeIdComparisonKey(worktreeId) + if (comparisonKey === null) { + return null + } + const equivalent = projected.filter( + (worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey + ) + return equivalent.length === 1 ? equivalent[0] : null } diff --git a/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts new file mode 100644 index 00000000000..f4e61a76381 --- /dev/null +++ b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts @@ -0,0 +1,273 @@ +/** + * Pins path-SPELLING parity between an `id:` selector and `path:`, deliberately not dedupe parity: + * where two same-host rows spell one directory, `path:` collapses them to the first while `id:` + * refuses as ambiguous, because this resolver also serves delete (#16243). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + BrowserWindow: { fromId: vi.fn((): unknown => null) }, + webContents: { fromId: vi.fn((): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } + } +}) +vi.mock('electron', () => electronMocks) + +const getSshGitProviderMock = vi.hoisted(() => vi.fn()) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: vi.fn(() => 0), + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', + requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) +})) + +const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) +vi.mock('../git/worktree', async (importOriginal) => ({ + ...(await importOriginal>()), + listWorktreesStrict: listWorktreesStrictMock +})) + +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { OrcaRuntimeService } from './orca-runtime' + +const REPO_ID = 'repo-local' +const REPO_PATH = '/srv/projects/app' +/** The spelling `git worktree list` reports. */ +const WORKTREE_PATH = '/srv/projects/workspaces/plugin-host' +const CANONICAL_ID = `${REPO_ID}::${WORKTREE_PATH}` + +/** One directory, other spellings a stored id can legitimately carry. */ +const ID_SPELLINGS: [label: string, worktreePath: string, repoPath?: string][] = [ + ['a trailing slash', `${WORKTREE_PATH}/`], + ['a doubled separator', '/srv/projects//workspaces/plugin-host'], + ['an NFD workspace name', '/srv/projects/workspaces/café-plugin'.normalize('NFD')], + // #15598/#15616: a Windows registration records backslashes; git reports forward slashes. + ['a backslash Windows spelling', 'D:\\Agentic\\game2\\battle-core', 'D:/Agentic/game2'] +] + +/** What a scan reports for a stored id: the same directory, canonically spelled. */ +function scannedSpellingOf(storedPath: string): string { + const slashed = /^[A-Za-z]:[\\/]/.test(storedPath) ? storedPath.replace(/\\/g, '/') : storedPath + return slashed.normalize('NFC').replace(/\/+/g, '/').replace(/\/$/, '') +} + +/** One registered repo whose worktree meta is writable, so a delete's `forgetLocal` is observable. */ +function makeStore(repoPath: string = REPO_PATH) { + const metaById: Record> = {} + const store = { + getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id), + getRepos: () => [ + { id: REPO_ID, path: repoPath, displayName: 'app', badgeColor: 'blue', addedAt: 1 } + ], + getAllWorktreeMeta: vi.fn(() => metaById), + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Record) => { + metaById[id] = { ...metaById[id], ...meta } + return metaById[id] + }, + removeWorktreeMeta: () => {}, + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn(), + removeWorkspaceLineage: vi.fn(), + getGitHubCache: () => undefined as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }), + getProjects: () => [] + } + return store +} + +/** `git worktree list` output: the main checkout plus one workspace at `worktreePath`. */ +function scanReports(worktreePath: string, repoPath: string = REPO_PATH): void { + listWorktreesStrictMock.mockResolvedValue([ + { path: repoPath, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { path: worktreePath, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false } + ]) +} + +type RemovalInternals = { + resolveWorktreeRemovalTarget: ( + worktreeSelector: string, + requiredHostId?: string + ) => Promise<{ id: string; repoId: string; path: string }> +} + +beforeEach(() => { + getSshGitProviderMock.mockReset() + listWorktreesStrictMock.mockReset() +}) + +describe('worktree id selectors vs. the path spelling git reports (#16243)', () => { + it.each(ID_SPELLINGS)( + 'resolves through the fleet path what `path:` resolves when the id carries %s', + async (_label, storedPath, repoPath) => { + scanReports(scannedSpellingOf(storedPath), repoPath) + const runtime = new OrcaRuntimeService(makeStore(repoPath) as never) + + // The CLI's shape, and the live data point: `path:` already resolves it. + const byPath = await runtime.showManagedWorktree(`path:${storedPath}`) + // The only shape the renderer can send must resolve the SAME workspace. + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::${storedPath}`) + ).resolves.toMatchObject({ id: byPath.id, path: byPath.path }) + } + ) + + it.each(ID_SPELLINGS)( + 'resolves a host-qualified removal target when the id carries %s', + async (_label, storedPath, repoPath) => { + scanReports(scannedSpellingOf(storedPath), repoPath) + const runtime = new OrcaRuntimeService(makeStore(repoPath) as never) + const internals = runtime as unknown as RemovalInternals + + // The scoped path the UI's delete takes must agree with the fleet path above. + await expect( + internals.resolveWorktreeRemovalTarget( + `id:${REPO_ID}::${storedPath}`, + LOCAL_EXECUTION_HOST_ID + ) + ).resolves.toMatchObject({ repoId: REPO_ID, path: scannedSpellingOf(storedPath) }) + } + ) + + it('still refuses an id whose path names a different workspace', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::/srv/projects/workspaces/other-plugin`) + ).rejects.toThrow('selector_not_found') + }) + + // STA-4343: matching across repo ids would delete a workspace the caller never confirmed. + it('still refuses the same path under a different repo id', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect(runtime.showManagedWorktree(`id:other-repo::${WORKTREE_PATH}/`)).rejects.toThrow( + 'selector_not_found' + ) + }) + + it('still refuses a removal qualified to a host that does not own the repo id', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + const internals = runtime as unknown as RemovalInternals + + await expect( + internals.resolveWorktreeRemovalTarget(`id:${CANONICAL_ID}/`, 'runtime:env-b') + ).rejects.toThrow('selector_not_found') + }) + + it('keeps `id:` and `path:` agreeing on a dot segment neither canonicalizes', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + const dotted = '/srv/projects/./workspaces/plugin-host' + + await expect(runtime.showManagedWorktree(`path:${dotted}`)).rejects.toThrow( + 'selector_not_found' + ) + await expect(runtime.showManagedWorktree(`id:${REPO_ID}::${dotted}`)).rejects.toThrow( + 'selector_not_found' + ) + }) + + // #15598/#15616: on Windows one checkout is recorded under both spellings, and git reports the + // forward-slash one. The backslash id itself rides the ID_SPELLINGS rows above; these pin the + // folding limits around it. + describe('Windows drive-letter spellings', () => { + const WINDOWS_REPO = 'D:/Agentic/game2' + const WINDOWS_WORKTREE = 'D:/Agentic/game2/battle-core' + + it('folds drive-letter case only for Windows paths, never for a POSIX path', async () => { + scanReports(WINDOWS_WORKTREE, WINDOWS_REPO) + const windowsRuntime = new OrcaRuntimeService(makeStore(WINDOWS_REPO) as never) + + // A Windows root is case-insensitive, as `path:` already treats it. + await expect( + windowsRuntime.showManagedWorktree(`id:${REPO_ID}::d:/agentic/game2/battle-core`) + ).resolves.toMatchObject({ path: WINDOWS_WORKTREE }) + + // A POSIX root is not: folding case there would merge distinct directories for a delete. + scanReports(WORKTREE_PATH) + const posixRuntime = new OrcaRuntimeService(makeStore() as never) + + await expect( + posixRuntime.showManagedWorktree(`id:${REPO_ID}::/SRV/projects/workspaces/plugin-host`) + ).rejects.toThrow('selector_not_found') + }) + + it('does not fold a backslash inside a POSIX path, where it is a valid filename character', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::/srv/projects\\workspaces\\plugin-host`) + ).rejects.toThrow('selector_not_found') + }) + }) + + // The fail-closed guard on a delete-capable resolver: two rows spelling one directory must not + // let a folded id pick one. `path:` collapses same-host duplicates; `id:` deliberately refuses. + it('refuses a folded id when two same-repo rows spell one directory', async () => { + listWorktreesStrictMock.mockResolvedValue([ + { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false }, + { + path: '/srv/projects//workspaces/plugin-host', + head: 'ghi', + branch: 'feature-2', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(makeStore() as never) + + // Matches neither row exactly, folds to both. + await expect(runtime.showManagedWorktree(`id:${CANONICAL_ID}/`)).rejects.toThrow( + 'selector_ambiguous' + ) + }) + + // Live-proof limit, pinned so nobody "fixes" the trimming: a folder-workspace id only trims a + // trailing slash at end of string, so one placed before the instance suffix stays exact-only. + it('keeps a folder-workspace id exact when a slash precedes the instance suffix', async () => { + const instanceSuffix = '::workspace:123e4567-e89b-12d3-a456-426614174000' + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::${WORKTREE_PATH}/${instanceSuffix}`) + ).rejects.toThrow('selector_not_found') + }) + + // #15616 guarantees malformed ids keep exact-match behavior; both sites must honour that too. + it.each([ + ['no repo boundary', 'not-an-id'], + ['an empty path', `${REPO_ID}::`] + ])('keeps exact matching for a malformed id with %s', async (_label, malformedId) => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + const internals = runtime as unknown as RemovalInternals + + await expect(runtime.showManagedWorktree(`id:${malformedId}`)).rejects.toThrow( + 'selector_not_found' + ) + await expect( + internals.resolveWorktreeRemovalTarget(`id:${malformedId}`, LOCAL_EXECUTION_HOST_ID) + ).rejects.toThrow('selector_not_found') + }) +}) diff --git a/src/shared/worktree/id.test.ts b/src/shared/worktree/id.test.ts index e64d27784f5..726e53abe5a 100644 --- a/src/shared/worktree/id.test.ts +++ b/src/shared/worktree/id.test.ts @@ -4,7 +4,8 @@ import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId, splitWorktreeId, - splitWorktreeIdForFilesystem + splitWorktreeIdForFilesystem, + worktreeIdComparisonKey } from './id' describe('WORKTREE_ID_SEPARATOR', () => { @@ -119,3 +120,65 @@ describe('getWorktreePathBasenameFromId', () => { expect(getWorktreePathBasenameFromId('repo-123::')).toBeNull() }) }) + +/** + * #16243: the renderer can only address a workspace by `id:::`, so the key must fold + * exactly the path spellings a `path:` selector already folds — and nothing more. + */ +describe('worktreeIdComparisonKey path-spelling parity for id: selectors (#16243)', () => { + const canonical = 'repo-123::/srv/workspaces/plugin' + const key = (worktreeId: string): string | null => worktreeIdComparisonKey(worktreeId) + + it('folds the path spellings a `path:` selector already accepts', () => { + expect(key('repo-123::/srv/workspaces/plugin/')).toBe(key(canonical)) + expect(key('repo-123::/srv//workspaces/plugin')).toBe(key(canonical)) + expect(key('repo-123::/srv/workspaces/Café'.normalize('NFD'))).toBe( + key('repo-123::/srv/workspaces/Café'.normalize('NFC')) + ) + }) + + it('folds no more loosely than `path:` does', () => { + // A leading `//` is a UNC root, not a doubled separator. + expect(key('repo-123://srv/workspaces/plugin')).not.toBe(key(canonical)) + // Dot segments are not canonicalized, so `id:` and `path:` still agree on refusing them. + expect(key('repo-123::/srv/./workspaces/plugin')).not.toBe(key(canonical)) + }) + + // #15598/#15616: the same Windows checkout is recorded with both separators. + it('folds Windows separator and drive-letter case, as `path:` already does', () => { + const windows = 'repo-123::D:/Agentic/game2' + expect(key('repo-123::D:\\Agentic\\game2')).toBe(key(windows)) + expect(key('repo-123::d:/agentic/game2')).toBe(key(windows)) + // A backslash is a valid POSIX filename character, so a POSIX path never folds it. + expect(key('repo-123::/srv\\workspaces')).not.toBe(key('repo-123::/srv/workspaces')) + // POSIX roots stay case-sensitive. + expect(key('repo-123::/srv/Workspaces')).not.toBe(key('repo-123::/srv/workspaces')) + }) + + it('keeps a UNC or WSL root distinct from a drive-letter location', () => { + // Different roots naming different filesystems must never collapse into one key. + expect(key('repo-123://server/share/game2')).not.toBe(key('repo-123::D:/server/share/game2')) + expect(key('repo-123://wsl.localhost/Ubuntu/home/dev/game2')).not.toBe( + key('repo-123::D:/home/dev/game2') + ) + // The two UNC aliases Windows exposes for one WSL distro are the same location. + expect(key('repo-123://wsl.localhost/Ubuntu/home/dev/game2')).toBe( + key('repo-123://wsl$/Ubuntu/home/dev/game2') + ) + }) + + it('never merges different repos, workspaces, or folder sessions', () => { + // STA-4343: the repo id stays exact, or a removal lands on a repo the caller never confirmed. + expect(key('repo-999::/srv/workspaces/plugin')).not.toBe(key(canonical)) + expect(key('repo-123::/srv/workspaces/other')).not.toBe(key(canonical)) + expect(key('repo-a::/srv/folder::workspace:123e4567-e89b-12d3-a456-426614174000')).not.toBe( + key('repo-a::/srv/folder::workspace:123e4567-e89b-12d3-a456-426614174001') + ) + }) + + it('returns null for ids with no repo boundary so callers keep exact matching', () => { + expect(key('repo-a')).toBeNull() + expect(key('repo-a::')).toBeNull() + expect(key('/srv/workspaces/plugin')).toBeNull() + }) +}) diff --git a/src/shared/worktree/id.ts b/src/shared/worktree/id.ts index d71cbac66eb..478ac6bfa76 100644 --- a/src/shared/worktree/id.ts +++ b/src/shared/worktree/id.ts @@ -1,3 +1,4 @@ +import { normalizeRuntimePathForComparison } from '../cross-platform-path' import { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format' export { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format' @@ -17,6 +18,22 @@ export function getRepoIdFromWorktreeId(worktreeId: string): string { return separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx) } +/** + * Canonical comparison form of a worktree id: the repoId is compared EXACT and only the path folds, + * through the same `normalizeRuntimePathForComparison` a `path:` selector has always applied and + * byte-exact id matching denied the renderer (#16243). Null for a malformed id, so callers keep + * exact matching for it. Comparison only — never persist or return this key. + */ +export function worktreeIdComparisonKey(worktreeId: string): string | null { + const parsed = splitWorktreeId(worktreeId) + if (!parsed || !parsed.repoId || !parsed.worktreePath) { + return null + } + return `${parsed.repoId}${WORKTREE_ID_SEPARATOR}${normalizeRuntimePathForComparison( + parsed.worktreePath + )}` +} + export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null { const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR) if (separatorIdx === -1) { From 9062494f9b232112cbbe137f985a6ff67c8da37b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:37:16 -0700 Subject: [PATCH 015/118] fix(ai-vault): stop a whole opencode.db failure reading as one skipped transcript (#16587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ai-vault): stop a whole opencode.db failure reading as one skipped transcript #15036 reported "1 transcript skipped / database is locked" with both Agent Session History scopes empty. Two separate defects. The panel counts every unkinded scan issue as a skipped transcript, so a failure that lost an entire *source* was reported as one lost *file*. The whole-database failure is now kinded `scope`, and an unknown `kind` from a newer host degrades to `scope` instead of failing validation and coming back unkinded — a mixed-version remote host previously turned a source-level failure into a phantom skipped transcript. The read also inherited sqlite3's 0 ms busy timeout, so a genuinely contended open failed in ~1 ms. It now opens once with a bounded timeout. No retry loop: sqlite's own busy handler already blocks and retries internally for the whole timeout, and WAL readers do not block on a writer at all (measured: 547/547 cross-process reads at timeout=0 while a writer held open transactions). Measured against a real Ubuntu-24.04 distro, Windows cannot take SQLite's file locks over \\wsl.localhost at all: an idle, never-WAL, nothing-attached database still answers SQLITE_BUSY, a 5 s busy timeout does not change it, and the identical bytes open fine once copied to local disk. So a lock-family error on that share never means "a writer holds it" and no timeout can help. The copy says so rather than sending the user after a write-ahead log that is not the problem. Restoring those sessions needs an in-distro read; that is a follow-up, and this PR no longer pretends a timeout will do it. immutable=1 is deliberately not used as a workaround: over the same share it opens and returns 100 of 150 rows, silently dropping everything still in the uncheckpointed -wal — in a history panel, exactly the newest sessions. * skip the provably futile busy wait on \\wsl.localhost paths --- .../session-list-result-validation.test.ts | 33 +++ .../session-list-result-validation.ts | 8 +- .../session-scanner-opencode-sqlite-list.ts | 43 ++-- ...ssion-scanner-opencode-sqlite-open.test.ts | 238 ++++++++++++++++++ .../session-scanner-opencode-sqlite-open.ts | 105 ++++++++ ...n-scanner-opencode-sqlite-worker-client.ts | 3 + .../session-scanner-opencode-sqlite.ts | 176 ++++++------- src/main/codex/codex-session-index-heal.ts | 3 +- src/main/sqlite/sqlite-read-failure.test.ts | 113 +++++++++ src/main/sqlite/sqlite-read-failure.ts | 77 ++++++ src/main/sqlite/sync-database.test.ts | 80 ++++++ .../ai-vault-scan-issue-state.test.ts | 18 ++ .../ai-vault-scan-issue-state.ts | 4 +- 13 files changed, 787 insertions(+), 114 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-open.ts create mode 100644 src/main/sqlite/sqlite-read-failure.test.ts create mode 100644 src/main/sqlite/sqlite-read-failure.ts diff --git a/src/main/ai-vault/session-list-result-validation.test.ts b/src/main/ai-vault/session-list-result-validation.test.ts index af1a5e34ec2..484b8c4eb6d 100644 --- a/src/main/ai-vault/session-list-result-validation.test.ts +++ b/src/main/ai-vault/session-list-result-validation.test.ts @@ -112,6 +112,39 @@ describe('parseAiVaultListResult', () => { }) }) + // #15036: a `kind` this build has never heard of used to fail the whole issue, + // and the fallback re-reported it as an unkinded entry — i.e. a newer host's + // whole-source failure came back to an older client as a skipped transcript. + it('keeps an issue whose kind this build does not know, as its own kinded row', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession()], + issues: [ + { + agent: 'opencode', + kind: 'source-from-a-newer-host', + path: '/home/ada/.local/share/opencode/opencode.db', + message: 'OpenCode history was skipped.' + } + ], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues).toEqual([ + expect.objectContaining({ kind: 'scope', message: 'OpenCode history was skipped.' }) + ]) + expect(parsed.issues.some((issue) => issue.message.includes('invalid'))).toBe(false) + }) + + it('leaves an unkinded issue unkinded so real skipped transcripts still count', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession()], + issues: [{ agent: 'codex', path: '/bad.jsonl', message: 'Malformed transcript' }], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues[0]?.kind).toBeUndefined() + }) + it('rejects a malformed result envelope', () => { expect(() => parseAiVaultListResult({ sessions: [] })).toThrow() }) diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index abcdd1612b3..06502cd4a0b 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -80,7 +80,13 @@ const aiVaultSessionSchema = z.object({ const aiVaultScanIssueSchema = z.object({ executionHostId: executionHostIdSchema.optional(), agent: z.string().min(1), - kind: z.enum(['host', 'scope', 'notice']).optional(), + // Why (#15036): a `kind` this build has never heard of used to fail the whole + // issue, which the fallback re-reported as an *unkinded* "invalid entry" — i.e. + // a newer host's source-level failure came back as a skipped transcript. An + // unknown kind degrades to 'scope' (its own muted row); absent stays absent. + kind: z + .union([z.enum(['host', 'scope', 'notice']), z.string().transform(() => 'scope' as const)]) + .optional(), path: z.string(), message: z.string() }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index 4b86d04161a..93f9daab98c 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -3,9 +3,12 @@ import { buildOpenCodeSqliteCandidatePath, splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import { + openCodeDatabaseScanIssue, + readOpenCodeDatabase +} from './session-scanner-opencode-sqlite-open' import type { SessionFileCandidate } from './session-scanner-types' -import { errorMessage } from './session-scanner-values' -import SyncDatabase from '../sqlite/sync-database' +import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' // Why: the SQLite session-list query + reader lives in its own electron-free @@ -18,12 +21,6 @@ type SessionRow = { time_updated: number } -function openReadonlyDatabase(dbPath: string): SyncDatabase { - const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) - db.pragma('query_only = ON') - return db -} - function canReadOpenCodeSessions(db: SyncDatabase): boolean { return ( tableExists(db, 'session') && @@ -48,6 +45,15 @@ function buildSessionListQuery(db: SyncDatabase, limited: boolean): string { ${limited ? 'LIMIT ?' : ''}` } +function readSessionRows(db: SyncDatabase, limit: number): SessionRow[] { + if (!canReadOpenCodeSessions(db)) { + return [] + } + const limited = Number.isFinite(limit) + const statement = db.prepare(buildSessionListQuery(db, limited)) + return (limited ? statement.all(limit) : statement.all()) as SessionRow[] +} + function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { const mtimeMs = typeof row.time_updated === 'number' && row.time_updated > 0 @@ -99,26 +105,17 @@ export async function listOpenCodeSqliteSessions(args: { }): Promise { const candidates: SessionFileCandidate[] = [] for (const dbPath of args.dbPaths) { - let db: SyncDatabase | null = null try { - db = openReadonlyDatabase(dbPath) - if (!canReadOpenCodeSessions(db)) { - continue - } - const limited = Number.isFinite(args.limit) - const statement = db.prepare(buildSessionListQuery(db, limited)) - const rows = (limited ? statement.all(args.limit) : statement.all()) as SessionRow[] + const rows = readOpenCodeDatabase({ + dbPath, + read: (db) => readSessionRows(db, args.limit) + }) for (const row of rows) { candidates.push(rowToCandidate(row, dbPath)) } } catch (err) { - args.issues.push({ - agent: 'opencode', - path: dbPath, - message: errorMessage(err) - }) - } finally { - db?.close() + // A whole DB failed, not one transcript: kinded so the panel says so. + args.issues.push(openCodeDatabaseScanIssue(dbPath, err)) } } return dedupeAndSortSqliteCandidates(candidates) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts new file mode 100644 index 00000000000..7bfd72f9b51 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { afterEach, describe, expect, it } from 'vitest' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import Database from '../sqlite/sync-database' +import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list' +import { + openCodeBusyTimeoutMs, + openCodeDatabaseScanIssue, + readOpenCodeDatabase +} from './session-scanner-opencode-sqlite-open' + +// Reproduces #15036: OpenCode holds opencode.db open while it runs, so a scan +// lands mid-write. The reader had no busy timeout, failed in ~1 ms with the bare +// driver string "database is locked", and one failed DB emptied both scopes. + +let tempDirs: string[] = [] +let lockHolders: Worker[] = [] + +afterEach(async () => { + await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate())) + lockHolders = [] + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +const SCHEMA = ` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + parent_id TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + ) +` + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-open-')) + tempDirs.push(dir) + return dir +} + +function seededDatabase(name: string, sessionId: string): string { + const path = join(tempDir(), name) + const db = new Database(path) + db.exec('PRAGMA journal_mode=DELETE') + db.exec(SCHEMA) + db.prepare('INSERT INTO session (id, time_created, time_updated) VALUES (?, ?, ?)').run( + sessionId, + 1_700_000_000_000, + 1_700_000_001_000 + ) + db.close() + return path +} + +// The lock holder must live on another thread: sqlite3_busy_timeout sleeps +// synchronously, so a same-thread timer could never fire to release it. +const LOCK_HOLDER_SOURCE = ` + const { parentPort, workerData } = require('node:worker_threads') + const { DatabaseSync } = require('node:sqlite') + const db = new DatabaseSync(workerData.path) + db.exec('BEGIN EXCLUSIVE') + db.exec("INSERT INTO session (id, time_created, time_updated) VALUES ('locked-write', 1, 1)") + parentPort.postMessage('locked') + setTimeout(() => { + db.exec('ROLLBACK') + db.close() + parentPort.postMessage('released') + }, workerData.holdMs) +` + +async function holdWriteLock(path: string, holdMs: number): Promise { + const worker = new Worker(LOCK_HOLDER_SOURCE, { eval: true, workerData: { path, holdMs } }) + lockHolders.push(worker) + await new Promise((resolve, reject) => { + worker.once('message', () => resolve()) + worker.once('error', reject) + }) +} + +describe('listOpenCodeSqliteSessions against a database OpenCode is writing to', () => { + it('reports the failure as a whole source, not as a skipped transcript', async () => { + const path = seededDatabase('opencode.db', 'session-a') + await holdWriteLock(path, 60_000) + const issues: AiVaultScanIssue[] = [] + + const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues }) + + expect(candidates).toEqual([]) + expect(issues).toHaveLength(1) + // The panel counts only unkinded issues as skipped transcripts. + expect(issues[0]?.kind).toBe('scope') + expect(issues[0]?.message).toContain('OpenCode is writing to opencode.db') + expect(issues[0]?.message).not.toBe('database is locked') + }) + + it('still returns sessions from every other database', async () => { + const contended = seededDatabase('opencode.db', 'session-a') + const healthy = seededDatabase('opencode-alt.db', 'session-b') + await holdWriteLock(contended, 60_000) + const issues: AiVaultScanIssue[] = [] + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [contended, healthy], + limit: 10, + issues + }) + + expect(candidates).toHaveLength(1) + expect(candidates[0]?.file.path).toContain('session-b') + expect(issues).toHaveLength(1) + }) + + it('reads the sessions once the write finishes inside the busy timeout', async () => { + const path = seededDatabase('opencode.db', 'session-a') + // Long enough that only a real busy timeout — not a lucky fast open — survives it. + await holdWriteLock(path, 900) + const issues: AiVaultScanIssue[] = [] + + const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues }) + + expect(issues).toEqual([]) + expect(candidates).toHaveLength(1) + expect(candidates[0]?.file.path).toContain('session-a') + }) +}) + +describe('readOpenCodeDatabase', () => { + it('closes the handle on the success path', () => { + const path = seededDatabase('opencode.db', 'session-a') + let captured: Database.Database | null = null + + const rows = readOpenCodeDatabase({ + dbPath: path, + read: (db) => { + captured = db + return db.prepare('SELECT id FROM session').all() + } + }) + + expect(rows).toEqual([{ id: 'session-a' }]) + expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i) + }) + + it('closes the handle when the read throws', () => { + const path = seededDatabase('opencode.db', 'session-a') + let captured: Database.Database | null = null + + expect(() => + readOpenCodeDatabase({ + dbPath: path, + read: (db) => { + captured = db + throw new Error('read blew up') + } + }) + ).toThrow('read blew up') + expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i) + }) +}) + +describe('openCodeBusyTimeoutMs', () => { + // Measured on a real Windows host against a real distro: a read over + // \\wsl.localhost fails whatever the timeout, and the wait is not bounded by + // it -- 1500 took ~2400 ms, 5000 took ~7250 ms, 0 failed in ~21 ms. Waiting + // there is dead time on every scan, and enough such databases would spend the + // list worker's whole deadline. + it('does not wait on a share where the wait provably cannot succeed', () => { + expect(openCodeBusyTimeoutMs('\\\\wsl.localhost\\Ubuntu\\home\\ada\\opencode.db')).toBe(0) + expect(openCodeBusyTimeoutMs('\\\\wsl$\\Ubuntu\\home\\ada\\opencode.db')).toBe(0) + }) + + it('still waits on a path where a writer really can be holding the lock', () => { + expect(openCodeBusyTimeoutMs('C:\\Users\\ada\\opencode.db')).toBe(1_500) + expect(openCodeBusyTimeoutMs('/home/ada/.local/share/opencode/opencode.db')).toBe(1_500) + }) +}) + +describe('openCodeDatabaseScanIssue', () => { + const cantOpen = Object.assign(new Error('unable to open database file'), { errcode: 14 }) + + it('names the wal-index over a WSL share rather than repeating the driver string', () => { + const issue = openCodeDatabaseScanIssue( + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db', + cantOpen + ) + + expect(issue.kind).toBe('scope') + expect(issue.message).toContain('\\\\wsl.localhost') + // Checkpointing cannot fix a share that refuses SQLite's locks, so the copy + // must not send the user after the write-ahead log. + expect(issue.message).not.toContain('write-ahead log') + }) + + it('keeps the advice generic for a non-WSL path', () => { + const issue = openCodeDatabaseScanIssue('/home/ada/.local/share/opencode/opencode.db', cantOpen) + + expect(issue.message).not.toContain('wsl.localhost') + expect(issue.message).toContain('write-ahead log') + }) + + // Measured against a real Ubuntu-24.04 distro: Windows cannot take SQLite's + // locks over \\wsl.localhost at all. An idle, never-WAL database answers + // SQLITE_BUSY the same way, while the identical bytes read fine once copied + // to local disk — so no busy timeout and no journal mode changes the outcome. + it('does not blame a live writer for a lock-family error over a WSL share', () => { + const busy = Object.assign(new Error('database is locked'), { errcode: 5 }) + const issue = openCodeDatabaseScanIssue( + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db', + busy + ) + + expect(issue.message).not.toContain('is writing to') + expect(issue.message).toContain('inside the distro') + }) + + it('still blames a live writer for the same error on a local path', () => { + const busy = Object.assign(new Error('database is locked'), { errcode: 5 }) + const issue = openCodeDatabaseScanIssue('/home/ada/.local/share/opencode/opencode.db', busy) + + expect(issue.message).toContain('is writing to') + }) + + it('reports anything else with its underlying cause', () => { + const issue = openCodeDatabaseScanIssue( + '/home/ada/.local/share/opencode/opencode.db', + Object.assign(new Error('database disk image is malformed'), { errcode: 11 }) + ) + + expect(issue.kind).toBe('scope') + expect(issue.message).not.toContain('write-ahead log') + expect(issue.message).toContain('database disk image is malformed') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts new file mode 100644 index 00000000000..84c2cca73d4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts @@ -0,0 +1,105 @@ +import { basename } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { isWslUncPath } from '../../shared/wsl-paths' +import SyncDatabase from '../sqlite/sync-database' +import { classifySqliteReadFailure } from '../sqlite/sqlite-read-failure' +import { errorMessage } from './session-scanner-values' + +// Why (#15036): the read used to inherit sqlite3's 0 ms busy timeout, so a +// genuinely contended open failed in ~1 ms. WAL readers almost never block on a +// writer, so this is insurance rather than the fix -- it stays a single bounded +// open, with no retry loop on top of sqlite's own busy handler. +const OPENCODE_SQLITE_BUSY_TIMEOUT_MS = 1_500 + +/** + * Open one OpenCode database for reading. + * + * Read-only, plus `query_only` as a belt-and-suspenders guard so a bug in a + * SELECT list can never mutate the user's opencode.db. + * @param dbPath - Absolute path to an opencode.db file. + * @returns An open handle the caller must close. + */ +/** + * Busy timeout for one database. + * + * Zero over the WSL share because waiting there is provably useless: Windows + * cannot take SQLite's locks over \\wsl.localhost at all, so the open fails + * whatever the timeout. Measured on a real host, the wait is not even bounded + * by the value — timeout 1500 took ~2400 ms and timeout 5000 took ~7250 ms, + * while 0 failed in ~21 ms. Paying that per database, per scan, buys nothing + * and enough such databases would spend the list worker's whole 30 s deadline. + * @param dbPath - Absolute path to an opencode.db file. + * @returns Milliseconds to let SQLite's busy handler wait. + */ +export function openCodeBusyTimeoutMs(dbPath: string): number { + return isWslUncPath(dbPath) ? 0 : OPENCODE_SQLITE_BUSY_TIMEOUT_MS +} + +function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { + readonly: true, + fileMustExist: true, + timeout: openCodeBusyTimeoutMs(dbPath) + }) + db.pragma('query_only = ON') + return db +} + +/** + * Run one synchronous read against an OpenCode database. + * @param args.dbPath - Absolute path to an opencode.db file. + * @param args.read - Synchronous reader; its result must be fully materialized. + * @returns The reader's value; rethrows whatever SQLite raised. + */ +export function readOpenCodeDatabase(args: { + dbPath: string + read: (db: SyncDatabase) => T +}): T { + const db = openOpenCodeDatabaseReadonly(args.dbPath) + try { + return args.read(db) + } finally { + db.close() + } +} + +/** + * Describe a whole-database read failure as a scan issue. + * + * Kinded so the panel renders this copy instead of counting a failed *source* + * as a skipped *transcript*; `scope` rather than a new member — see + * `aiVaultScanIssueSchema` in session-list-result-validation.ts. + * @param dbPath - Absolute path to the opencode.db that could not be read. + * @param error - The thrown value from the read. + * @returns A kinded scan issue with actionable copy. + */ +export function openCodeDatabaseScanIssue(dbPath: string, error: unknown): AiVaultScanIssue { + const name = basename(dbPath) + // `fileMustExist` already proved the database file is there before SQLite ran, + // so this needs no fs probe of its own — an extra sync stat on a 9p share is + // exactly the hang the WSL transcript gate exists to prevent. + const kind = classifySqliteReadFailure({ error, databaseFileExists: true }) + // Why: measured against a real distro. Windows cannot take SQLite's file locks + // over \\wsl.localhost at all — an idle, never-WAL, nothing-attached database + // answers SQLITE_BUSY just the same, and a 5 s busy timeout does not change it, + // while the identical bytes copied to local disk open fine. So on this share a + // lock-family error never means "a writer holds it", and no timeout can help. + const overWslShare = isWslUncPath(dbPath) + const detail = + kind === 'contended' && !overWslShare + ? `OpenCode is writing to ${name} right now, so its history was skipped. It is read again on the next refresh.` + : kind === 'unreadable' + ? `OpenCode history in ${name} could not be read: ${errorMessage(error)}` + : `OpenCode history in ${name} could not be read. ${unreadableShareAdvice(dbPath)}` + return { agent: 'opencode', kind: 'scope', path: dbPath, message: detail } +} + +function unreadableShareAdvice(dbPath: string): string { + // Named only when the evidence supports it; a generic share gets generic copy. + // Deliberately not "flush the write-ahead log": checkpointing changes nothing + // here, and telling the user to try it would send them after a fix that cannot + // work. The share itself is the blocker. + return isWslUncPath(dbPath) + ? 'Windows cannot open SQLite databases over the \\\\wsl.localhost share, so this history has to be read from inside the distro.' + : 'Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.' +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index ef156b0b3f4..e8fc78907ba 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -94,8 +94,10 @@ export class OpenCodeSqliteWorkerClient { return value.candidates } catch (err) { if (err instanceof OpenCodeSqliteWorkerUnavailableError) { + // Kinded: a whole source failed, not a transcript. args.issues.push({ agent: 'opencode', + kind: 'scope', path: args.dbPaths[0] ?? 'opencode.db', message: 'OpenCode history was skipped because its background scanner could not start; the app remains responsive.' @@ -106,6 +108,7 @@ export class OpenCodeSqliteWorkerClient { // scan, surfaced as one scan issue rather than an unbounded stall. args.issues.push({ agent: 'opencode', + kind: 'scope', path: args.dbPaths[0] ?? 'opencode.db', message: `OpenCode history scan did not complete: ${errorMessage(err)}` }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index c0ffdbfb894..322a38f214f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -9,8 +9,9 @@ import { normalizeFullFirstUserPromptText, shouldCaptureFullFirstUserPrompt } from './session-scanner-first-user-prompt' +import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open' import { normalizeTitleText } from './session-scanner-values' -import SyncDatabase from '../sqlite/sync-database' +import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' // Why: OpenCode 1.17.x migrated session storage from per-session JSON files @@ -53,14 +54,6 @@ type PreviewRow = { summary_body: string | null } -function openReadonlyDatabase(dbPath: string): SyncDatabase { - const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) - // Why: belt-and-suspenders guard so a bug in the SELECT list can never - // mutate the user's opencode.db. - db.pragma('query_only = ON') - return db -} - function canReadOpenCodeSessions(db: SyncDatabase): boolean { return ( tableExists(db, 'session') && @@ -256,90 +249,97 @@ export async function parseOpenCodeSqliteSession(args: { sessionId: string platform: NodeJS.Platform }): Promise { - const { dbPath, sessionId, platform } = args - let db: SyncDatabase | null = null - try { - db = openReadonlyDatabase(dbPath) - if (!canReadOpenCodeSessions(db)) { - return null - } - const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined - if (!row || row.id !== sessionId) { - return null - } + return readOpenCodeDatabase({ + dbPath: args.dbPath, + read: (db) => readSession({ db, ...args }) + }) +} + +// Extracted so the open wrapper owns the handle's lifetime. +function readSession(args: { + db: SyncDatabase + dbPath: string + sessionId: string + platform: NodeJS.Platform +}): AiVaultSession | null { + const { db, dbPath, sessionId, platform } = args + if (!canReadOpenCodeSessions(db)) { + return null + } + const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined + if (!row || row.id !== sessionId) { + return null + } - const mtimeMs = - typeof row.time_updated === 'number' && row.time_updated > 0 - ? row.time_updated - : row.time_created - // Why: discovery uses a synthetic db#session path only for parser routing. - // The UI's log open/reveal actions need a real filesystem path. - const accumulator = createAccumulator({ - agent: 'opencode', - file: { - path: dbPath, - mtimeMs, - modifiedAt: new Date(mtimeMs).toISOString() - }, - sessionId - }) - accumulator.title = normalizeTitleText(row.title ?? '') - accumulator.cwd = row.directory - accumulator.model = extractModelId(row.model_json) - accumulator.totalTokens = - (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) - accumulator.messageCount = row.message_count ?? 0 - updateTimeline(accumulator, row.time_created) - updateTimeline(accumulator, row.time_updated) + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + // Why: discovery uses a synthetic db#session path only for parser routing. + // The UI's log open/reveal actions need a real filesystem path. + const accumulator = createAccumulator({ + agent: 'opencode', + file: { + path: dbPath, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + sessionId + }) + accumulator.title = normalizeTitleText(row.title ?? '') + accumulator.cwd = row.directory + accumulator.model = extractModelId(row.model_json) + accumulator.totalTokens = + (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) + accumulator.messageCount = row.message_count ?? 0 + updateTimeline(accumulator, row.time_created) + updateTimeline(accumulator, row.time_updated) - const previewSql = buildPreviewQuery(db) - if (previewSql) { - // Why: SQL already dropped anything older than the newest-N window, so the - // accumulator never shifts and cannot detect the truncation itself. Ask for - // one extra row so an exactly-full window is not mistaken for a trimmed one. - const probedRows = db - .prepare(previewSql) - .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[] - if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) { - accumulator.previewMessagesTruncated = true + const previewSql = buildPreviewQuery(db) + if (previewSql) { + // Why: SQL already dropped anything older than the newest-N window, so the + // accumulator never shifts and cannot detect the truncation itself. Ask for + // one extra row so an exactly-full window is not mistaken for a trimmed one. + const probedRows = db + .prepare(previewSql) + .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[] + if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) { + accumulator.previewMessagesTruncated = true + } + // Newest-first, so the probe row to drop is the oldest one at the tail. + const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT) + // Why: query returns newest-first; push in chronological order so the + // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT + // messages. + for (let i = previewRows.length - 1; i >= 0; i--) { + const previewRow = previewRows[i] + if (!previewRow) { + continue } - // Newest-first, so the probe row to drop is the oldest one at the tail. - const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT) - // Why: query returns newest-first; push in chronological order so the - // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT - // messages. - for (let i = previewRows.length - 1; i >= 0; i--) { - const previewRow = previewRows[i] - if (!previewRow) { - continue - } - const text = extractPartText(previewRow.part_data) - if (!text) { - continue - } - addPreviewMessage(accumulator, { - role: mapPreviewRole(previewRow.role), - text, - timestamp: previewRow.time_created, - // Preview window is newest-N; first-prompt is loaded separately below. - seedFirstUserPrompt: false - }) - if (previewRow.role === 'user' && !accumulator.title) { - accumulator.title = - normalizeTitleText(previewRow.summary_title ?? '') || - normalizeTitleText(previewRow.summary_body ?? '') - } + const text = extractPartText(previewRow.part_data) + if (!text) { + continue } + addPreviewMessage(accumulator, { + role: mapPreviewRole(previewRow.role), + text, + timestamp: previewRow.time_created, + // Preview window is newest-N; first-prompt is loaded separately below. + seedFirstUserPrompt: false + }) + if (previewRow.role === 'user' && !accumulator.title) { + accumulator.title = + normalizeTitleText(previewRow.summary_title ?? '') || + normalizeTitleText(previewRow.summary_body ?? '') + } } + } - // Why: list preview only joins the newest messages. On-demand copy needs the - // session's earliest real user text part, not a later turn still in the window. - if (shouldCaptureFullFirstUserPrompt()) { - accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) - } + // Why: list preview only joins the newest messages. On-demand copy needs the + // session's earliest real user text part, not a later turn still in the window. + if (shouldCaptureFullFirstUserPrompt()) { + accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) + } - return finalizeSession(accumulator, platform) - } finally { - db?.close() - } + return finalizeSession(accumulator, platform) } diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index 645d96a76b4..12747312646 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -1,5 +1,6 @@ import { dirname, join } from 'node:path' import { resolveCodexCommand } from '../codex-cli/command' +import { isTransientSqliteContention } from '../sqlite/sqlite-read-failure' import { getSpawnArgsForWindows } from '../win32-utils' import { getCodexSessionBackfillStateDirPath } from './codex-home-paths' import { resolveCodexSessionBackfillPaths } from './codex-session-backfill' @@ -231,7 +232,7 @@ async function healOneThread( recordHealOutcome(paths, thread, 'missing') return } - if (/SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i.test(message)) { + if (isTransientSqliteContention(message)) { // Why: an active Codex process can briefly own sqlite; leave the id off // the ledger and abort this pass so a later startup resumes it. throw error diff --git a/src/main/sqlite/sqlite-read-failure.test.ts b/src/main/sqlite/sqlite-read-failure.test.ts new file mode 100644 index 00000000000..8bd43d54eaa --- /dev/null +++ b/src/main/sqlite/sqlite-read-failure.test.ts @@ -0,0 +1,113 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import SyncDatabase from './sync-database' +import { classifySqliteReadFailure, isTransientSqliteContention } from './sqlite-read-failure' + +// Error codes here are the ones a real node:sqlite open produces: a contended +// database reports errcode 5 ("database is locked"), while a read-only WAL open +// with no usable -shm reports errcode 14 ("unable to open database file"). The +// two need opposite responses, so the classifier must never conflate them. + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +function contendedDatabase(): { path: string; release: () => void } { + const dir = mkdtempSync(join(tmpdir(), 'orca-sqlite-failure-')) + tempDirs.push(dir) + const path = join(dir, 'contended.db') + const writer = new SyncDatabase(path) + writer.exec('PRAGMA journal_mode=DELETE') + writer.exec('CREATE TABLE session (id TEXT PRIMARY KEY)') + writer.exec('BEGIN EXCLUSIVE') + writer.exec("INSERT INTO session VALUES ('a')") + return { + path, + release: () => { + writer.exec('COMMIT') + writer.close() + } + } +} + +describe('isTransientSqliteContention', () => { + it('recognizes a real SQLITE_BUSY thrown by a read-only open', () => { + const contended = contendedDatabase() + let thrown: unknown + try { + new SyncDatabase(contended.path, { readonly: true, timeout: 0 }) + .prepare('SELECT id FROM session') + .all() + } catch (error) { + thrown = error + } finally { + contended.release() + } + + expect((thrown as { errcode?: number }).errcode).toBe(5) + expect(isTransientSqliteContention(thrown)).toBe(true) + }) + + it('recognizes a relayed message with no errcode, as the Codex heal pass sees it', () => { + expect( + isTransientSqliteContention('codex app-server thread/read failed: database is locked') + ).toBe(true) + expect(isTransientSqliteContention(new Error('SQLITE_LOCKED: table is locked'))).toBe(true) + }) + + it('reads extended result codes through their primary code', () => { + // SQLITE_BUSY_SNAPSHOT (517) and SQLITE_BUSY_RECOVERY (261) both pack 5. + expect(isTransientSqliteContention({ errcode: 517, message: 'busy snapshot' })).toBe(true) + expect(isTransientSqliteContention({ errcode: 261, message: 'recovery' })).toBe(true) + }) + + it('does not treat an unreadable or absent database as contention', () => { + expect(isTransientSqliteContention(new Error('file is not a database'))).toBe(false) + expect( + isTransientSqliteContention({ errcode: 14, message: 'unable to open database file' }) + ).toBe(false) + }) +}) + +describe('classifySqliteReadFailure', () => { + it('classifies contention as retryable regardless of the file evidence', () => { + const error = { errcode: 5, message: 'database is locked' } + + expect(classifySqliteReadFailure({ error, databaseFileExists: true })).toBe('contended') + expect(classifySqliteReadFailure({ error, databaseFileExists: false })).toBe('contended') + }) + + it('classifies SQLITE_CANTOPEN against a present database as an unreachable wal-index', () => { + expect( + classifySqliteReadFailure({ + error: { errcode: 14, message: 'unable to open database file' }, + databaseFileExists: true + }) + ).toBe('wal-index-unavailable') + }) + + it('does not blame the wal-index when the database file itself is gone', () => { + expect( + classifySqliteReadFailure({ + error: { errcode: 14, message: 'unable to open database file' }, + databaseFileExists: false + }) + ).toBe('unreadable') + }) + + it('falls back to unreadable for anything else', () => { + expect( + classifySqliteReadFailure({ + error: { errcode: 11, message: 'database disk image is malformed' }, + databaseFileExists: true + }) + ).toBe('unreadable') + }) +}) diff --git a/src/main/sqlite/sqlite-read-failure.ts b/src/main/sqlite/sqlite-read-failure.ts new file mode 100644 index 00000000000..f96176eee0a --- /dev/null +++ b/src/main/sqlite/sqlite-read-failure.ts @@ -0,0 +1,77 @@ +// Why (#15036): a read-only SQLite open can fail for reasons that demand +// opposite responses, and both arrive as an opaque driver string. Contention is +// transient — retry later, never persist it as a permanent failure. A missing +// wal-index is structural — retrying forever cannot fix it. + +/** Primary result codes; extended codes pack the primary code in the low byte. */ +const SQLITE_BUSY = 5 +const SQLITE_LOCKED = 6 +const SQLITE_CANTOPEN = 14 + +// Shared with the Codex index-heal pass, which only ever sees a relayed message +// string (app-server RPC drops `errcode`), so message matching is not optional. +const CONTENTION_MESSAGE = /SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i + +function primaryErrcode(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('errcode' in error)) { + return null + } + const errcode = (error as { errcode?: unknown }).errcode + return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * True when a SQLite failure means "someone else holds the database right now". + * @param error - The thrown value from a `node:sqlite` call or a relayed message. + * @returns Whether the caller should retry later rather than record a failure. + */ +export function isTransientSqliteContention(error: unknown): boolean { + const errcode = primaryErrcode(error) + if (errcode === SQLITE_BUSY || errcode === SQLITE_LOCKED) { + return true + } + return CONTENTION_MESSAGE.test(errorText(error)) +} + +export type SqliteReadFailureKind = + /** Another connection holds the lock; the same read can succeed later. */ + | 'contended' + /** + * The database file itself is present, but SQLite still could not open the + * set of files it needs — in practice the `-wal`/`-shm` pair a read-only WAL + * open requires, which shares that cannot host SQLite shared memory (9p + * `\\wsl.localhost`, SMB, SSHFS) refuse. Retrying cannot fix it. + */ + | 'wal-index-unavailable' + /** Anything else: corrupt file, wrong schema, permissions. */ + | 'unreadable' + +/** + * Classify why a read-only SQLite open or query failed. + * + * Pure: the caller supplies the "does the database file exist" evidence, so + * neither branch needs a filesystem probe of its own. That matters on a WSL 9p + * share, where an extra sync stat is exactly the hang the transcript gate exists + * to prevent. + * @param args.error - The thrown value. + * @param args.databaseFileExists - Whether the main database file was present. + * @returns Which of the three failure modes the error represents. + */ +export function classifySqliteReadFailure(args: { + error: unknown + databaseFileExists: boolean +}): SqliteReadFailureKind { + if (isTransientSqliteContention(args.error)) { + return 'contended' + } + // SQLITE_CANTOPEN against a database that is right there names a companion + // file — the wal-index a read-only WAL open needs — not the database itself. + if (primaryErrcode(args.error) === SQLITE_CANTOPEN && args.databaseFileExists) { + return 'wal-index-unavailable' + } + return 'unreadable' +} diff --git a/src/main/sqlite/sync-database.test.ts b/src/main/sqlite/sync-database.test.ts index 34677095b26..fe3ba7e388d 100644 --- a/src/main/sqlite/sync-database.test.ts +++ b/src/main/sqlite/sync-database.test.ts @@ -1,11 +1,13 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { Worker } from 'node:worker_threads' import { afterEach, describe, expect, it } from 'vitest' import SyncDatabase from './sync-database' const temporaryDirectories: string[] = [] const openDatabases: SyncDatabase.Database[] = [] +const lockHolders: Worker[] = [] async function createDatabase(): Promise { const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-')) @@ -20,6 +22,7 @@ async function createDatabase(): Promise { } afterEach(async () => { + await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate())) for (const db of openDatabases.splice(0)) { try { db.close() @@ -151,3 +154,80 @@ describe('SyncDatabase statement cache', () => { ) }) }) + +// Why (#15036): the OpenCode readers opened read-only with no busy timeout, so a +// contended DB failed in ~1 ms and emptied the whole panel. These pin that the +// wrapper really forwards `timeout` into sqlite3_busy_timeout. +describe('SyncDatabase read-only opens under contention', () => { + // The lock holder must live on another thread: sqlite3_busy_timeout sleeps + // synchronously, so a same-thread timer could never fire to release it. + const WRITER_SOURCE = ` + const { parentPort, workerData } = require('node:worker_threads') + const { DatabaseSync } = require('node:sqlite') + const db = new DatabaseSync(workerData.path) + db.exec('PRAGMA journal_mode=DELETE') + db.exec("CREATE TABLE items (id TEXT PRIMARY KEY); INSERT INTO items VALUES ('a')") + db.exec('BEGIN EXCLUSIVE') + db.exec("INSERT INTO items VALUES ('b')") + parentPort.postMessage('locked') + setTimeout(() => { + db.exec('COMMIT') + db.close() + parentPort.postMessage('released') + }, workerData.holdMs) + ` + + async function contendedDatabase(holdMs: number): Promise<{ path: string }> { + const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-busy-')) + temporaryDirectories.push(directory) + const path = join(directory, 'contended.db') + const worker = new Worker(WRITER_SOURCE, { eval: true, workerData: { path, holdMs } }) + lockHolders.push(worker) + await new Promise((resolve, reject) => { + worker.once('message', () => resolve()) + worker.once('error', reject) + }) + return { path } + } + + it('fails immediately with SQLITE_BUSY when no timeout is given', async () => { + const contended = await contendedDatabase(10_000) + const startedAt = Date.now() + + let thrown: unknown + try { + new SyncDatabase(contended.path, { readonly: true }).prepare('SELECT id FROM items').all() + } catch (error) { + thrown = error + } + + expect((thrown as { errcode?: number }).errcode).toBe(5) + expect((thrown as Error).message).toContain('database is locked') + expect(Date.now() - startedAt).toBeLessThan(200) + }) + + it('waits for the configured busy timeout before giving up', async () => { + const contended = await contendedDatabase(10_000) + const startedAt = Date.now() + + expect(() => + new SyncDatabase(contended.path, { readonly: true, timeout: 400 }) + .prepare('SELECT id FROM items') + .all() + ).toThrow(/database is locked/) + + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(350) + }) + + it('succeeds once the writer commits inside the busy timeout', async () => { + const contended = await contendedDatabase(150) + + const reader = new SyncDatabase(contended.path, { readonly: true, timeout: 5_000 }) + openDatabases.push(reader) + + expect(reader.prepare('SELECT id FROM items ORDER BY id').all()).toEqual([ + { id: 'a' }, + { id: 'b' } + ]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts index f4e4755eaf7..80ab908cd1a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts @@ -78,6 +78,24 @@ describe('aiVaultScanNoticeIssues', () => { expect(skippedAiVaultTranscriptCount(partial)).toBe(1) }) + // #15036: a locked opencode.db holds every OpenCode session, so reporting it + // as "1 transcript skipped" both understates the loss and buries the cause in + // a bare driver string. + it('gives an unreadable OpenCode database its own row instead of a skipped count', () => { + const sourceIssue = { + agent: 'opencode' as const, + kind: 'scope' as const, + path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db', + message: + 'OpenCode is writing to opencode.db right now, so its history was skipped. It is read again on the next refresh.' + } + const empty = result([], [sourceIssue]) + + expect(skippedAiVaultTranscriptCount(empty)).toBe(0) + expect(skippedAiVaultTranscriptReasons(empty)).toEqual([]) + expect(aiVaultScanNoticeIssues(empty)).toEqual([sourceIssue]) + }) + it('does not repeat the blocking issue as a notice row', () => { const hostIssue = { executionHostId: 'ssh:dev-box' as const, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts index d73046f5580..5bf60d3a17a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts @@ -12,7 +12,9 @@ export function blockingAiVaultScanIssue( // Host and scope issues carry their own scanner-authored copy, so they get their // own rows instead of being counted as skipped transcripts — a partial scan // (one SSH host down, rest fine) must not report a connectivity failure as a -// skipped transcript file. +// skipped transcript file, and an unreadable *source* (a whole locked +// opencode.db holding every OpenCode session) must not read as "1 transcript +// skipped". export function aiVaultScanNoticeIssues(result: AiVaultListResult | null): AiVaultScanIssue[] { if (!result) { return [] From 5631aa00dd55787b8bc6e8074aad0a42ee7674d5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:18:51 -0700 Subject: [PATCH 016/118] =?UTF-8?q?feat(orcad):=20items=202=E2=80=937=20?= =?UTF-8?q?=E2=80=94=20degradation,=20natives,=20daemon,=20ops,=20deploy?= =?UTF-8?q?=20(#16398)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ports): stop joining an undefined resourcesPath on a non-Electron host `resolveWorkerEntryPath` branched on `isPackaged` alone and joined `process.resourcesPath`. orcad reports `isPackaged` true — correctly, it is a production build, and ~15 consumers read it that way to gate HTTPS-only skill downloads and the real CLI name — but `process.resourcesPath` is Electron-only and `undefined` under plain Node. So the packaged branch threw `TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string` where a clean "worker unavailable" was the honest outcome. The type said `resourcesPath: string`, which is how it went unnoticed; it is now `string | undefined`, so the compiler carries the fact. A host with no Electron resources tree has no asar to look in, so it falls back to the module directory and lets the caller report a missing worker. Found by the item 1 agent while auditing the same `isPackaged` defect class in the watcher. Verified in both directions: reverting the guard reproduces the TypeError. * feat(orcad): prove node-pty loads before anything requires it Of the two ways node-pty fails, only one is catchable. A missing module throws MODULE_NOT_FOUND. A module built against the wrong libc or Node ABI is refused by the dynamic loader, and in the worst case takes the process down before any handler exists — that is #9902, which crashed the desktop app on Ubuntu 20.04 before a window appeared. There was no libc or ABI precondition anywhere in the tree. So orcad now proves the load in a CHILD process, from main.ts, before anything requires node-pty. Whatever the child does — throw, abort, die on a signal — is data rather than our own death, and the operator gets a sentence naming the host's libc, Node ABI and prebuild slot plus the command to run. Proven-unloadable exits 78 (EX_CONFIG), so a supervisor does not restart an unequippable host forever. A probe that never answered is unverifiable, not blocked: refusing to boot on an inconclusive signal would take down hosts that work. The child dlopens the file node-pty would have chosen, before requiring the package. node-pty's loader walks several directories and rethrows only the LAST error, so a refused binary reads as "Cannot find module ./prebuilds/..." — which sends the operator to install a module that is already there. It also reports through stdout: node echoes the whole -e source above a stack trace, and matching tokens against stderr made the probe's own source text answer for the verdict. Verdicts reach clients as a terminal_unavailable degradation alongside the existing browser_unavailable one, through the same cause-registry shape. degradations[].code is now an open vocabulary; clients already render only `message`. Prebuilds are compiled from PATCHED sources — the patch IS the glibc-floor fix, so an upstream tarball reproduces #9902 — into linux-{x64,arm64}-{glibc,musl} and darwin-{x64,arm64} slots. libc is in the slot name because node-pty's loader falls back to prebuilds/- and cannot tell glibc from musl. orcad installs the matching slot at boot, so a host with no compiler serves terminals. The relay's five pure toolchain-diagnosis functions moved to a transport-free module so the Node bundle can reuse them without dragging ssh2 in behind them; the relay keeps its API by re-export. macOS gets `xcode-select --install` rather than the cross-distro apt/dnf/pacman/apk menu, every line of which is wrong there. * test(orcad): pin the node-pty precondition to ground truth, not a prepared host CI's test shard runs `vitest` directly, so `ensure-native-runtime --runtime=node` never prepares node-pty for the Node ABI — `degraded` is the correct verdict there, and asserting 'ok' encoded an environment the shard does not have. Asserting whatever it returned would be vacuous, so the expectation is now derived from an independent require() of node-pty. Verified it still bites: forcing the precondition to always report 'ok' fails the suite. * feat(orcad): run the terminal daemon, and the ops contract around it orcad declared `canRecoverPersistentLocalPtys: () => false` because it did not run the terminal daemon, so every restart, update and rollback SIGKILLed every running terminal — on the host whose selling point is that work survives the client going away. That is the one property `ssh-execution-boundary.md` recommends the peer model for. Item 4 — the daemon: - Port the launch path off electron: `daemon-init.ts`, `daemon-host-relocation.ts` and `observability/logs-directory.ts` now read the `AppEnvironment` port. Relocation additionally asks whether the app root is an asar archive rather than whether the build is packaged, so a Node host answering `isPackaged() === true` no longer walks into an Electron-only NSIS-escape path (same precedent as `parcel-watcher-entry-path.ts`). - `build-orcad.mjs` emits `daemon-entry.js` beside `orcad.js`, scans the forked children's metafiles for electron/node:sqlite, and load-checks the child under plain Node. - orcad spawns and adopts the daemon; shutdown disconnects and never kills it. `canRecoverPersistentLocalPtys` now reads the live provider and is false under degraded routing, where fresh terminals would die with the process. Item 3 — the ops contract (docs/reference/orcad-operations.md): - Bind policy: `--bind`, default loopback, pinned so neither `orca serve`'s wide default nor the connected-device widen can override it, and so a paired client cannot rebind the listener from outside. - Instance lock on the data root before profile load, scoped to the runtime role so it never refuses a restart that a live daemon makes worthwhile. - Supervision: exit codes a supervisor can act on (78 = do not retry), second-signal escalation, a shutdown deadline, and crash-loop containment on daemon respawn. - Health in the readiness payload: build hash, Node ABI, and a PTY self-test that spans both processes — the daemon spawns a real PTY in its own process and the verdict crosses its socket. Both bundle load-checks now assert on exit codes: these bundles are minified onto one line, so Node's uncaught-exception report echoes every string literal in the bundle and the previous message match passed against a bundle that never loaded. * feat(orcad): deploy, activate and roll back a versioned orcad install Plan items 6 and 7 from docs/design/shipping-orcad.html. Install reuses the relay's transaction verbatim — per-version lock, staged SFTP write, .install-complete sentinel, stale-lock recovery — under a parameterized namespace, so orcad-/ sits beside relay-/ permanently (§06). Parameterizing GC is the trap that creates: each model now collects only its own directories, enforced twice (prefix-scoped remote listing plus a local ownership re-check), and a client picks its model from how the host is registered, never from what it finds on disk. Activation is separate from installation, because a versioned directory selects nothing. A candidate is launched, publishes orca_server_ready, and only becomes active if its cross-process health payload passes: right build hash, listening, daemon live, PTY self-test green. A rejected candidate is stopped and the incumbent restarted, so a careful deploy cannot cause the outage it was being careful about. Update and rollback are shaped by the daemon. An update restarts orcad, the daemon outlives it, and the surviving daemon was forked from the outgoing bundle — so live terminals defer the update rather than proceed, and GC pins the active version, the rollback target and the live daemon's bundle. Orca's persisted state carries no schema version, so rollback restores a pre-activation snapshot rather than trusting backward-readability; the point past which it is unsafe is the first terminal created after activation, which the snapshot cannot describe and the surviving daemon still owns. Running the generated shell for real found two bugs the text assertions missed: tar members re-quoted inside a shell variable captured nothing, and kill -0 reports a zombie as alive. * test(orcad): assert the precondition is self-consistent, not environment-shaped The real-host case cannot predict a status: CI's shard runs vitest directly, so node-pty is never built for the Node ABI and 'degraded' is correct there, while a prepared checkout gives 'ok'. The previous attempt used require('node-pty') as ground truth, which resolves the JS wrapper while the native binding loads lazily — it proved strictly less than the precondition checks, and failed CI for exactly that reason. What is invariant on a host with node-pty installed: never 'blocked', and never a degraded verdict carrying an unestablished reason. The injected-input tests keep the logic coverage. * fix(orcad): drop an eslint-disable the rule no longer needs * test(orcad): separate slot placement from the load verdict Both remaining CI failures were the same shape: tests reaching into node_modules for a pty.node that only exists after `ensure-native-runtime --runtime=node`, which CI's shard never runs because it invokes vitest directly. Slot *placement* is the logic worth checking on every host, so it now uses a synthetic payload and asserts the verdict stays honest about not loading. The three assertions that genuinely need a Node-ABI binding are gated on it existing. Verified: breaking slot installation fails both placement tests; with the real pty.node hidden the file is 17 passed / 3 skipped instead of ENOENT. * test(orcad): gate the load-dependent cases on a real load, not on the file existing CI ships a pty.node built for Electron's ABI, so existsSync was true while require still failed — the gate ran exactly the tests that host can never satisfy. It now probes the binding in a child process, so a bad one cannot take the runner down. The self-consistency assertion also allowed too little: 'blocked' is the honest verdict for a corrupt binding, alongside 'ok' on a prepared host and 'degraded' on an unprepared one. What stays invariant is that anything other than 'ok' names an established cause, so a terminal is never declined for a reason nobody worked out. Verified against all three host states: prepared (19 passed), unprepared, and a corrupt binding (17 passed / 3 skipped, no failures). * test(orcad): gate on the whole premise — binding AND spawn-helper CI has a loadable pty.node but no spawn-helper, and a slot without the helper is legitimately 'degraded'. So the previous gate let a test run whose premise ('a complete slot yields ok') that host cannot satisfy. Verified in both states: with the helper present 19 pass; with it removed the load-dependent cases skip (17 passed / 3 skipped) instead of failing. * fix(orcad): preserve degradation types after rebase --- .gitignore | 1 + config/scripts/build-orcad-prebuilds.mjs | 212 ++++++++++ config/scripts/build-orcad-prebuilds.test.mjs | 121 ++++++ config/scripts/build-orcad.mjs | 161 ++++++-- docs/reference/linux-glibc-compatibility.md | 26 ++ docs/reference/orcad-operations.md | 190 +++++++++ package.json | 1 + .../daemon/daemon-entry-path-layouts.test.ts | 115 ++++++ src/main/daemon/daemon-entry.ts | 5 +- .../daemon/daemon-host-relocation.test.ts | 65 +++- src/main/daemon/daemon-host-relocation.ts | 32 +- .../daemon-init-child-readiness.test.ts | 1 - .../daemon-init-child-startup-failure.test.ts | 1 - .../daemon/daemon-init-dependency-mocks.ts | 15 +- .../daemon-init-endpoint-adoption.test.ts | 1 - src/main/daemon/daemon-init-fresh-import.ts | 13 + ...mon-init-live-session-preservation.test.ts | 1 - src/main/daemon/daemon-init-mock-types.ts | 1 + ...mon-init-packaged-bundle-staleness.test.ts | 1 - .../daemon-init-provider-installation.test.ts | 23 +- .../daemon-init-replacement-reporting.test.ts | 1 - .../daemon-init-restart-sequence.test.ts | 1 - .../daemon-init-wedged-daemon-grace.test.ts | 1 - src/main/daemon/daemon-init.ts | 95 ++++- .../daemon/daemon-respawn-throttle.test.ts | 95 +++++ src/main/daemon/daemon-respawn-throttle.ts | 84 ++++ src/main/daemon/daemon-spawner.ts | 24 ++ src/main/observability/logs-directory.ts | 34 +- src/main/orcad/main-preflight-order.test.ts | 30 ++ src/main/orcad/main.ts | 23 +- src/main/orcad/native-host-abi.test.ts | 110 ++++++ src/main/orcad/native-host-abi.ts | 127 ++++++ src/main/orcad/node-pty-prebuilt-slot.test.ts | 119 ++++++ src/main/orcad/node-pty-prebuilt-slot.ts | 113 ++++++ src/main/orcad/node-pty-precondition.test.ts | 358 +++++++++++++++++ src/main/orcad/node-pty-precondition.ts | 365 ++++++++++++++++++ src/main/orcad/orcad-bind-address.test.ts | 45 +++ src/main/orcad/orcad-bind-address.ts | 66 ++++ .../orcad-bundle-native-load-order.test.ts | 91 +++++ .../orcad/orcad-daemon-supervision.test.ts | 85 ++++ src/main/orcad/orcad-daemon-supervision.ts | 67 ++++ src/main/orcad/orcad-entry.ts | 107 ++++- src/main/orcad/orcad-health.test.ts | 152 ++++++++ src/main/orcad/orcad-health.ts | 155 ++++++++ src/main/orcad/orcad-instance-lock.test.ts | 151 ++++++++ src/main/orcad/orcad-instance-lock.ts | 324 ++++++++++++++++ src/main/orcad/orcad-launch-contract.test.ts | 43 +++ src/main/orcad/orcad-native-preflight.test.ts | 125 ++++++ src/main/orcad/orcad-native-preflight.ts | 72 ++++ .../ports/port-scan-command-client.test.ts | 26 ++ src/main/ports/port-scan-command-client.ts | 10 +- .../runtime/native-terminal-availability.ts | 51 +++ src/main/runtime/orca-runtime.test.ts | 39 ++ src/main/runtime/orca-runtime.ts | 7 + .../runtime-rpc-websocket-bind-host.test.ts | 103 +++++ src/main/runtime/runtime-rpc.ts | 32 +- src/main/server/serve-readiness.test.ts | 64 +++ src/main/server/serve-readiness.ts | 24 +- src/main/ssh/build-toolchain-diagnosis.ts | 166 ++++++++ src/main/ssh/orcad-activation-gate.test.ts | 164 ++++++++ src/main/ssh/orcad-activation-gate.ts | 155 ++++++++ src/main/ssh/orcad-activation-record-store.ts | 48 +++ src/main/ssh/orcad-activation-record.test.ts | 102 +++++ src/main/ssh/orcad-activation-record.ts | 175 +++++++++ src/main/ssh/orcad-local-build-hash.ts | 22 ++ src/main/ssh/orcad-remote-deploy.test.ts | 264 +++++++++++++ src/main/ssh/orcad-remote-deploy.ts | 340 ++++++++++++++++ src/main/ssh/orcad-remote-gc.test.ts | 153 ++++++++ src/main/ssh/orcad-remote-gc.ts | 74 ++++ src/main/ssh/orcad-remote-host-support.ts | 50 +++ src/main/ssh/orcad-remote-launch.test.ts | 121 ++++++ src/main/ssh/orcad-remote-launch.ts | 178 +++++++++ src/main/ssh/orcad-remote-process-control.ts | 73 ++++ src/main/ssh/orcad-remote-rollback.test.ts | 206 ++++++++++ src/main/ssh/orcad-remote-rollback.ts | 244 ++++++++++++ ...-remote-shell-commands.integration.test.ts | 189 +++++++++ src/main/ssh/orcad-state-snapshot.test.ts | 100 +++++ src/main/ssh/orcad-state-snapshot.ts | 173 +++++++++ src/main/ssh/orcad-update-plan.test.ts | 177 +++++++++ src/main/ssh/orcad-update-plan.ts | 234 +++++++++++ .../ssh/remote-install-coexistence.test.ts | 76 ++++ src/main/ssh/remote-install-coexistence.ts | 83 ++++ src/main/ssh/remote-install-gc.ts | 286 ++++++++++++++ src/main/ssh/remote-install-model.test.ts | 70 ++++ src/main/ssh/remote-install-model.ts | 143 +++++++ src/main/ssh/ssh-relay-build-toolchain.ts | 174 +-------- src/main/ssh/ssh-relay-install-namespace.ts | 28 +- src/main/ssh/ssh-relay-versioned-install.ts | 252 ++---------- src/main/ssh/ssh-remote-commands.ts | 51 ++- src/shared/orcad-artifacts.ts | 42 ++ src/shared/runtime-capability-degradation.ts | 64 +++ src/shared/runtime-session-contracts.ts | 33 +- src/shared/runtime-types.ts | 12 +- 93 files changed, 8587 insertions(+), 540 deletions(-) create mode 100644 config/scripts/build-orcad-prebuilds.mjs create mode 100644 config/scripts/build-orcad-prebuilds.test.mjs create mode 100644 docs/reference/orcad-operations.md create mode 100644 src/main/daemon/daemon-entry-path-layouts.test.ts create mode 100644 src/main/daemon/daemon-respawn-throttle.test.ts create mode 100644 src/main/daemon/daemon-respawn-throttle.ts create mode 100644 src/main/orcad/main-preflight-order.test.ts create mode 100644 src/main/orcad/native-host-abi.test.ts create mode 100644 src/main/orcad/native-host-abi.ts create mode 100644 src/main/orcad/node-pty-prebuilt-slot.test.ts create mode 100644 src/main/orcad/node-pty-prebuilt-slot.ts create mode 100644 src/main/orcad/node-pty-precondition.test.ts create mode 100644 src/main/orcad/node-pty-precondition.ts create mode 100644 src/main/orcad/orcad-bind-address.test.ts create mode 100644 src/main/orcad/orcad-bind-address.ts create mode 100644 src/main/orcad/orcad-bundle-native-load-order.test.ts create mode 100644 src/main/orcad/orcad-daemon-supervision.test.ts create mode 100644 src/main/orcad/orcad-daemon-supervision.ts create mode 100644 src/main/orcad/orcad-health.test.ts create mode 100644 src/main/orcad/orcad-health.ts create mode 100644 src/main/orcad/orcad-instance-lock.test.ts create mode 100644 src/main/orcad/orcad-instance-lock.ts create mode 100644 src/main/orcad/orcad-launch-contract.test.ts create mode 100644 src/main/orcad/orcad-native-preflight.test.ts create mode 100644 src/main/orcad/orcad-native-preflight.ts create mode 100644 src/main/runtime/native-terminal-availability.ts create mode 100644 src/main/ssh/build-toolchain-diagnosis.ts create mode 100644 src/main/ssh/orcad-activation-gate.test.ts create mode 100644 src/main/ssh/orcad-activation-gate.ts create mode 100644 src/main/ssh/orcad-activation-record-store.ts create mode 100644 src/main/ssh/orcad-activation-record.test.ts create mode 100644 src/main/ssh/orcad-activation-record.ts create mode 100644 src/main/ssh/orcad-local-build-hash.ts create mode 100644 src/main/ssh/orcad-remote-deploy.test.ts create mode 100644 src/main/ssh/orcad-remote-deploy.ts create mode 100644 src/main/ssh/orcad-remote-gc.test.ts create mode 100644 src/main/ssh/orcad-remote-gc.ts create mode 100644 src/main/ssh/orcad-remote-host-support.ts create mode 100644 src/main/ssh/orcad-remote-launch.test.ts create mode 100644 src/main/ssh/orcad-remote-launch.ts create mode 100644 src/main/ssh/orcad-remote-process-control.ts create mode 100644 src/main/ssh/orcad-remote-rollback.test.ts create mode 100644 src/main/ssh/orcad-remote-rollback.ts create mode 100644 src/main/ssh/orcad-remote-shell-commands.integration.test.ts create mode 100644 src/main/ssh/orcad-state-snapshot.test.ts create mode 100644 src/main/ssh/orcad-state-snapshot.ts create mode 100644 src/main/ssh/orcad-update-plan.test.ts create mode 100644 src/main/ssh/orcad-update-plan.ts create mode 100644 src/main/ssh/remote-install-coexistence.test.ts create mode 100644 src/main/ssh/remote-install-coexistence.ts create mode 100644 src/main/ssh/remote-install-gc.ts create mode 100644 src/main/ssh/remote-install-model.test.ts create mode 100644 src/main/ssh/remote-install-model.ts create mode 100644 src/shared/orcad-artifacts.ts create mode 100644 src/shared/runtime-capability-degradation.ts diff --git a/.gitignore b/.gitignore index efaee0a2e38..a08e1058eeb 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ docs/** !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md +!docs/reference/orcad-operations.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/windows-process-enumeration.md !docs/reference/wsl-runner-verification.md diff --git a/config/scripts/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs new file mode 100644 index 00000000000..679625b9f6d --- /dev/null +++ b/config/scripts/build-orcad-prebuilds.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * Build one node-pty prebuilt for the CURRENT platform/arch/libc and file it in orcad's + * prebuilds matrix, so a deployment target needs no C/C++ toolchain. + * + * node-pty is the only ABI-sensitive native module orcad requires. It is also PATCHED in + * this repo (config/patches/node-pty@1.1.0.patch), and that patch is the glibc-floor fix: + * `.symver` pins on openpty/forkpty/pthread_sigmask plus the `--no-as-needed` ldflags that + * keep libutil/libpthread in DT_NEEDED. An upstream prebuilt has none of it and reproduces + * #9902. So the matrix is compiled from patched sources here, and this script refuses to + * run if the patch is not in the tree it is about to compile. + * + * orcad pins its own Node runtime, so the ABI dimension is fixed and the matrix varies + * only platform/arch/libc: + * linux-x64-glibc, linux-arm64-glibc, linux-x64-musl, linux-arm64-musl, + * darwin-x64, darwin-arm64 + * + * CI runs this once per slot, each inside the container that owns that libc/arch, and + * merges the resulting `out/orcad/prebuilds` trees. `--slot=` forces the label so + * the glibc/musl distinction is recorded from the container rather than detected. + * + * Usage: + * node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl] + * node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate + */ +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { spawnSync } from 'node:child_process' + +const require = createRequire(import.meta.url) +const ROOT = join(import.meta.dirname, '..', '..') +const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds') + +/** Every slot a shipped matrix must fill. The single source of truth for the matrix. */ +export const MATRIX_SLOTS = [ + 'linux-x64-glibc', + 'linux-arm64-glibc', + 'linux-x64-musl', + 'linux-arm64-musl', + 'darwin-x64', + 'darwin-arm64' +] + +/** + * Why the report header and not `ldd`: `glibcVersionRuntime` is present only when the + * process is linked against glibc, and musl images have no `ldd` worth parsing. + */ +export function detectLibc(platform = process.platform, header = readReportHeader()) { + if (platform !== 'linux') { + return 'none' + } + return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl' +} + +function readReportHeader() { + try { + return process.report?.getReport?.()?.header + } catch { + return undefined + } +} + +export function slotName(argv = process.argv, platform = process.platform, arch = process.arch) { + const forced = argv.find((arg) => arg.startsWith('--slot=')) + if (forced) { + return forced.slice('--slot='.length) + } + const libc = detectLibc(platform) + return libc === 'none' ? `${platform}-${arch}` : `${platform}-${arch}-${libc}` +} + +/** + * The patch is what holds the Ubuntu 20.04 floor. Compiling without it produces a binary + * that loads fine on the build host and dies on the target — the exact failure the matrix + * exists to prevent, now baked into a shipped artifact instead of a first-connect error. + */ +export function assertNodePtyPatchApplied(nodePtyDir) { + const bindingGyp = readFileSync(join(nodePtyDir, 'binding.gyp'), 'utf8') + const ptySource = readFileSync(join(nodePtyDir, 'src', 'unix', 'pty.cc'), 'utf8') + const missing = [] + if (!bindingGyp.includes('--no-as-needed,-l:libutil.so.1')) { + missing.push("binding.gyp is missing the '--no-as-needed,-l:libutil.so.1' ldflag") + } + if (!ptySource.includes('.symver openpty,openpty@')) { + missing.push('src/unix/pty.cc is missing the .symver glibc pins') + } + if (missing.length > 0) { + throw new Error( + [ + '[orcad-prebuilds] refusing to build: config/patches/node-pty@1.1.0.patch is not applied.', + ...missing.map((line) => ` - ${line}`), + 'A prebuilt compiled without it will not load on Ubuntu 20.04 (see', + 'docs/reference/linux-glibc-compatibility.md and #9902). Run `pnpm install` to apply patches.' + ].join('\n') + ) + } +} + +export function readManifest(prebuildsDir) { + try { + return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) + } catch { + return null + } +} + +/** + * Why merge rather than overwrite: CI builds one slot per container and merges the trees. + * A manifest that records only the last slot would erase every other container's record, + * and `--require-slots` would then reject a complete matrix. + */ +export function mergeManifest(existing, next) { + const slots = new Set([...(existing?.slots ?? []), next.slot]) + return { + module: 'node-pty', + version: next.version, + nodeAbi: next.nodeAbi, + slots: [...slots].sort() + } +} + +function nodePtyDir() { + return dirname(require.resolve('node-pty/package.json')) +} + +function compileNodePty(dir) { + const built = join(dir, 'build', 'Release', 'pty.node') + if (existsSync(built)) { + console.log(`[orcad-prebuilds] reusing existing build at ${built}`) + return built + } + console.log('[orcad-prebuilds] compiling node-pty from patched source ...') + const result = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['node-gyp', 'rebuild'], { + cwd: dir, + stdio: 'inherit', + env: process.env, + windowsHide: true + }) + if (result.status !== 0) { + throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`) + } + if (!existsSync(built)) { + throw new Error(`[orcad-prebuilds] node-gyp succeeded but ${built} is missing`) + } + return built +} + +function requireSlots() { + const manifest = readManifest(PREBUILDS_DIR) + const have = new Set(manifest?.slots ?? []) + const missing = MATRIX_SLOTS.filter((slot) => !have.has(slot)) + if (missing.length > 0) { + console.error( + `[orcad-prebuilds] matrix incomplete — missing ${missing.join(', ')}. ` + + 'Hosts on those slots fall back to a source build and need a C/C++ toolchain.' + ) + process.exitCode = 1 + return + } + console.log(`[orcad-prebuilds] matrix complete — ${MATRIX_SLOTS.length} slots`) +} + +function build() { + const dir = nodePtyDir() + assertNodePtyPatchApplied(dir) + const slot = slotName() + const slotDir = join(PREBUILDS_DIR, slot) + mkdirSync(slotDir, { recursive: true }) + + const builtBinary = compileNodePty(dir) + copyFileSync(builtBinary, join(slotDir, 'pty.node')) + console.log(`[orcad-prebuilds] stored ${slot}/pty.node`) + + // Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper, + // so a slot without it installs cleanly and then fails ENOENT the first time a user + // opens a terminal. Windows has no spawn-helper. + if (process.platform !== 'win32') { + const helperSource = join(dirname(builtBinary), 'spawn-helper') + if (!existsSync(helperSource)) { + throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`) + } + copyFileSync(helperSource, join(slotDir, 'spawn-helper')) + console.log(`[orcad-prebuilds] stored ${slot}/spawn-helper`) + } + + // The static floor gate, applied to the artifact we are about to ship rather than only + // to the packaged desktop app. objdump is Linux-only, which is where the floor lives. + if (process.platform === 'linux') { + const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs') + verifyLinuxGlibcFloor(slotDir) + } + + const manifest = mergeManifest(readManifest(PREBUILDS_DIR), { + slot, + version: require('node-pty/package.json').version, + nodeAbi: process.versions.modules + }) + writeFileSync(join(PREBUILDS_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) + console.log( + `[orcad-prebuilds] manifest: node-pty ${manifest.version}, ABI ${manifest.nodeAbi}, slots ${manifest.slots.join(', ')}` + ) +} + +if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) { + if (process.argv.includes('--require-slots')) { + requireSlots() + } else { + build() + } +} diff --git a/config/scripts/build-orcad-prebuilds.test.mjs b/config/scripts/build-orcad-prebuilds.test.mjs new file mode 100644 index 00000000000..84c0e83c18f --- /dev/null +++ b/config/scripts/build-orcad-prebuilds.test.mjs @@ -0,0 +1,121 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + assertNodePtyPatchApplied, + detectLibc, + MATRIX_SLOTS, + mergeManifest, + readManifest, + slotName +} from './build-orcad-prebuilds.mjs' + +const PATCHED_BINDING_GYP = "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']" +const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);' + +const dirs = [] +const stage = (bindingGyp, ptyCc) => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-src-')) + dirs.push(dir) + mkdirSync(join(dir, 'src', 'unix'), { recursive: true }) + writeFileSync(join(dir, 'binding.gyp'), bindingGyp) + writeFileSync(join(dir, 'src', 'unix', 'pty.cc'), ptyCc) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('assertNodePtyPatchApplied', () => { + it('accepts a tree with both halves of the glibc-floor fix', () => { + expect(() => + assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, PATCHED_PTY_CC)) + ).not.toThrow() + }) + + it('refuses to build when the ldflags half is missing', () => { + // The .symver pins alone let gcc's --as-needed drop libutil/libpthread from + // DT_NEEDED, which loads on the build host and fails on Ubuntu 20.04 — #9902 again, + // this time baked into a shipped prebuilt. + expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", PATCHED_PTY_CC))).toThrow( + /--no-as-needed,-l:libutil\.so\.1/ + ) + }) + + it('refuses to build when the .symver pins are missing', () => { + expect(() => assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, '// upstream'))).toThrow( + /\.symver glibc pins/ + ) + }) + + it('names the patch and the doc so the fix is findable', () => { + expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", '// upstream'))).toThrow( + /config\/patches\/node-pty@1\.1\.0\.patch/ + ) + }) +}) + +describe('slot naming', () => { + it('covers every platform orcad ships to', () => { + expect([...MATRIX_SLOTS].sort()).toEqual([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64-glibc', + 'linux-arm64-musl', + 'linux-x64-glibc', + 'linux-x64-musl' + ]) + }) + + it('lets CI force the label so the container decides glibc vs musl', () => { + // Detection inside a container that happens to run a differently-linked Node would + // file the build under the wrong slot. The forced label must beat detection outright, + // so assert against one detection could never produce for this platform/arch. + expect(slotName(['node', 'x', '--slot=linux-x64-glibc'], 'linux', 'arm64')).toBe( + 'linux-x64-glibc' + ) + }) + + it('omits the libc dimension off Linux', () => { + expect(slotName([], 'darwin', 'arm64')).toBe('darwin-arm64') + }) + + it('reads glibc from the report header and musl from its absence', () => { + expect(detectLibc('linux', { glibcVersionRuntime: '2.31' })).toBe('glibc') + expect(detectLibc('linux', {})).toBe('musl') + expect(detectLibc('darwin', { glibcVersionRuntime: '2.31' })).toBe('none') + }) +}) + +describe('mergeManifest', () => { + it('accumulates slots across the per-container CI runs that build them', () => { + // Overwriting would erase every other container's record, and the release gate would + // then reject a matrix that is actually complete. + const first = mergeManifest(null, { slot: 'linux-x64-glibc', version: '1.1.0', nodeAbi: '127' }) + const second = mergeManifest(first, { + slot: 'linux-arm64-musl', + version: '1.1.0', + nodeAbi: '127' + }) + + expect(second.slots).toEqual(['linux-arm64-musl', 'linux-x64-glibc']) + expect(second).toMatchObject({ module: 'node-pty', version: '1.1.0', nodeAbi: '127' }) + }) + + it('does not duplicate a slot rebuilt twice', () => { + const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }) + expect(mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots) + .toEqual(['darwin-arm64']) + }) +}) + +describe('readManifest', () => { + it('returns null instead of throwing when no matrix has been built', () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-manifest-')) + dirs.push(dir) + expect(readManifest(dir)).toBeNull() + }) +}) diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 05948acd529..69c12f0ecc9 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -9,10 +9,25 @@ */ import { fork, spawnSync } from 'node:child_process' import { build } from 'esbuild' -import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { arch, platform, tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' +import { + ORCAD_VERSION, + ORCAD_VERSION_FILENAME, + orcadArtifactFilenames +} from '../../src/shared/orcad-artifacts.ts' const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = join(ROOT, 'out', 'orcad') @@ -22,6 +37,11 @@ const ENTRY = join(ROOT, 'src/main/orcad/main.ts') // looks for it in the app root. A deployment has no desktop out/main to fall back to. const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts') const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') +// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process, +// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every +// orcad restart would SIGKILL every running terminal. +const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts') +const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}` const OUT_FILE = join(OUT_DIR, 'orcad.js') const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME) @@ -64,20 +84,31 @@ if (process.platform !== 'win32') { chmodSync(AGENT_BROWSER_OUTPUT, 0o755) } -await build({ - entryPoints: [WATCHER_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: WATCHER_OUT_FILE, - external: EXTERNAL, - plugins: [externalNativeAddons], - minify: true, - sourcemap: false, - define: { 'process.env.NODE_ENV': '"production"' }, - logLevel: 'error' -}) +/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source + * directory under `outdir`, and both children must land flat beside orcad.js — that is where + * their runtime resolvers look for them. */ +function buildForkedChild(entryPoint, outfile) { + return build({ + entryPoints: [entryPoint], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile, + external: EXTERNAL, + plugins: [externalNativeAddons], + metafile: true, + minify: true, + sourcemap: false, + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'error' + }) +} + +const childResults = await Promise.all([ + buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), + buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE) +]) const result = await build({ entryPoints: [ENTRY], @@ -95,29 +126,36 @@ const result = await build({ logLevel: 'error' }) -const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint) +const output = Object.values(result.metafile.outputs).find( + (o) => o.entryPoint === 'src/main/orcad/main.ts' +) // Why check `original` and not just `path`: when electron is bundleable, esbuild // rewrites `path` to the resolved file under node_modules and the naive check passes // while the package is very much in the bundle. -const electronImporters = new Set() -for (const [file, info] of Object.entries(result.metafile.inputs)) { - for (const imported of info.imports ?? []) { - const specifier = imported.original ?? imported.path - if (specifier === 'electron' || specifier.startsWith('electron/')) { - electronImporters.add(file) - } - } -} -const sqliteImporters = new Set() -for (const [file, info] of Object.entries(result.metafile.inputs)) { - for (const imported of info.imports ?? []) { - const specifier = imported.original ?? imported.path - if (specifier === 'node:sqlite') { - sqliteImporters.add(file) +// Why both metafiles: the forked children ship in the same deployment and run under the +// same plain Node. A daemon-entry that reached electron would fail at fork time, on the +// path whose whole point is that terminals survive. +function collectImporters(metafiles, matches) { + const importers = new Set() + for (const metafile of metafiles) { + for (const [file, info] of Object.entries(metafile.inputs)) { + for (const imported of info.imports ?? []) { + if (matches(imported.original ?? imported.path)) { + importers.add(file) + } + } } } + return importers } +const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)] +const electronImporters = collectImporters( + metafiles, + (specifier) => specifier === 'electron' || specifier.startsWith('electron/') +) +const sqliteImporters = collectImporters(metafiles, (specifier) => specifier === 'node:sqlite') + const graphErrors = [] if (electronImporters.size > 0) { graphErrors.push( @@ -147,23 +185,53 @@ if (graphErrors.length > 0) { // dynamic require, a missing native, a top-level throw. The plain-node-entry-guard // smoke-loads its entries for exactly this reason, and orcad cannot join that guard // because it is an esbuild artifact rather than a rollup input. + // Why an exit code and not a message match: these bundles are minified onto one line, so + // Node's uncaught-exception report echoes that whole line — which contains every string + // literal in the bundle. A crash therefore "matches" any expected message, and a textual + // assertion passes against a bundle that never loaded. const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], { encoding: 'utf8', timeout: 60_000 }) const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}` - if ( - smoke.error || - smoke.signal || - !/Unknown argument: --orcad-smoke-load-check/.test(smokeOutput) - ) { + if (smoke.error || smoke.signal || smoke.status !== 0) { console.error( `[build-orcad] the bundle did not load under plain Node.\n` + - `Expected argv rejection, got signal=${smoke.signal ?? 'none'} ` + + `Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` + + `signal=${smoke.signal ?? 'none'} ` + `error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}` ) process.exitCode = 1 } + // Why require + parseArgs and not a real daemon: requiring the bundle evaluates every + // top-level import, and calling its exported argv parser proves the entry's own code is + // there rather than a graph that merely resolved. Booting one would need a socket, a + // token and a PTY — `smoke:orcad-terminal` does that end to end, through orcad. + // The verdict is carried by the exit code for the same minification reason as above. + const daemonSmoke = spawnSync( + process.execPath, + [ + '-e', + `const mod = require(${JSON.stringify(DAEMON_OUT_FILE)})\n` + + `if (typeof mod.parseArgs !== 'function') { process.exit(3) }\n` + + `try { mod.parseArgs([]); process.exit(4) } catch { process.exit(0) }` + ], + { + encoding: 'utf8', + timeout: 60_000, + env: { ...process.env, ORCA_DAEMON_ENTRY_LOAD_CHECK: '1' } + } + ) + const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}` + if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) { + console.error( + `[build-orcad] the daemon child did not load under plain Node.\n` + + `Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` + + `signal=${daemonSmoke.signal ?? 'none'} ` + + `error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}` + ) + process.exitCode = 1 + } const watcherFailure = await smokeLoadWatcherChild() if (watcherFailure) { console.error( @@ -173,9 +241,26 @@ if (graphErrors.length > 0) { } } +// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on +// this string, so two different builds carrying one version would share a directory — and an +// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale +// bytes while reporting the new version. if (process.exitCode !== 1) { + const hash = createHash('sha256') + for (const filename of orcadArtifactFilenames()) { + const artifactPath = join(OUT_DIR, filename) + if (!existsSync(artifactPath)) { + throw new Error( + `orcad declares ${filename} in ORCAD_ARTIFACTS but never emitted it. Add the build ` + + 'step, or drop it from src/shared/orcad-artifacts.ts.' + ) + } + hash.update(readFileSync(artifactPath)) + } + const fullVersion = `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}` + writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion) console.log( - `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.` + `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.` ) } diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md index 60d855c7372..bf98dbdcb80 100644 --- a/docs/reference/linux-glibc-compatibility.md +++ b/docs/reference/linux-glibc-compatibility.md @@ -82,6 +82,32 @@ libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the app itself still launches on stock 20.04. +**3. Check before loading, on hosts that ship without a compiler (`orcad`).** +The two gates above protect the packaged desktop app, where the binary is built and +verified by the same pipeline. `orcad` is deployed to hosts Orca never built on, so it +adds a runtime precondition +([`src/main/orcad/node-pty-precondition.ts`](../../src/main/orcad/node-pty-precondition.ts)), +run from `main.ts` before anything requires `node-pty`. It loads the addon in a **child +process**, so a binary the loader refuses — or one that aborts outright — is data rather +than this process's death, and the operator gets a sentence naming the host's libc, its +Node ABI, its prebuild slot and the command to run. A proven-unloadable binary exits 78 +(`EX_CONFIG`) instead of reaching the `require`; a probe that never answered is reported +as unverifiable and boots anyway, because a silent probe is not evidence. Whatever it +finds is published in `status.get`'s `degradations[]` under `terminal_unavailable`. + +**4. Ship the binary, built from patched sources.** +[`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs) +(`pnpm run build:orcad-prebuilds`, after `build:orcad`) compiles node-pty for the current +host and files it under `out/orcad/prebuilds//`, where a slot is +`linux-{x64,arm64}-{glibc,musl}` or `darwin-{x64,arm64}`. libc is part of the slot name +because node-pty's own loader falls back to `prebuilds/-` and cannot tell +glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`. +The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not +applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather +than a first-connect error. CI runs it once per slot inside the matching container +(`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with +a hole in the matrix. + ## Adding or upgrading a native dependency - Prefer packages that ship prebuilt binaries compiled against an old toolchain diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md new file mode 100644 index 00000000000..3ef3fdea37a --- /dev/null +++ b/docs/reference/orcad-operations.md @@ -0,0 +1,190 @@ +# Running orcad + +`orcad` is the Orca runtime served from plain Node. This is the contract between it and +whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its +readiness payload actually proves. + +Design background: `docs/design/shipping-orcad.html` §00c and §04. + +## Two long-lived processes, not one + +A deployment is **orcad** plus **the terminal daemon**. + +| | orcad | terminal daemon | +| --- | --- | --- | +| Started by | the supervisor | orcad, detached | +| Owns | RPC, git, worktrees, persistence | every local PTY | +| Lifetime | one supervised run | **outlives orcad** | +| Endpoint | `ws://:` | `/daemon/daemon-v.sock` | + +The daemon outliving orcad is the property the whole peer model is recommended for +(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime +restart, so a restart, an update or a rollback does not destroy running work. Everything +below exists to keep that true. + +**Consequence for supervision:** orcad's shutdown path calls `disconnectDaemon()`, never +`shutdownDaemon()`. A supervisor that reaps orcad's whole process group — systemd's +`KillMode=control-group` — kills the daemon too and turns every restart back into data loss. +Use `KillMode=mixed` (the default) or `process`, and never `--send-sigkill` on the group. + +## Bind policy + +`--bind `, **default `127.0.0.1`**. + +Only literal IPs are accepted; hostnames are refused because DNS would decide which +interface got bound. `localhost` maps to `127.0.0.1`. `0.0.0.0` / `::` are the explicit +opt-ins to network reach, and the startup log says so on every launch. + +The bind is **pinned**, not defaulted. Two things widen the desktop's listener on their own — +`orca serve`'s wide default, and a startup where some device has connected before — and an +unattended host's exposure must be exactly what the operator asked for on every launch. A +mobile pairing offer, which normally rebinds to all interfaces, is refused while the bind is +pinned to loopback and reports `network_exposure_failed` rather than advertising an endpoint +nothing can reach. + +Under the shipping design a client reaches a remote orcad over an SSH local port-forward, so +loopback is the correct default and the pairing credential travels over SSH. + +## Data root and the instance lock + +The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`. + +Before the profile index or the store is touched, orcad takes `/orcad.lock`. +It refuses to start when: + +| Code | Meaning | +| --- | --- | +| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) | +| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened | +| `orcad_instance_lock_held` | another live orcad owns this root | +| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity | +| `orcad_data_root_unusable` | the root cannot be created, stat'd or written | + +A root that is merely too permissive and that we own is tightened to `0700` rather than +refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal +is a private root, and refusing when we could just fix it helps nobody. We refuse when the +permissions are not ours to fix. Windows is exempt from the owner and mode checks: ACLs are +not expressible as a POSIX mode, and `statSync().mode` there reports a synthesized one. + +A dead holder's record is reclaimed (PID plus process start time, so a recycled PID does not +read as alive). A record belonging to a different identity is never reclaimed. + +**The lock scopes one role — who is the runtime.** It deliberately says nothing about the +daemon, which lives under `/daemon` and fences its own endpoint with its own PID +record. A lock that asked "is any process using this root" would refuse exactly the restarts +a live daemon makes worthwhile. + +## Supervision + +### Who supervises orcad + +An external supervisor (systemd, launchd, a process manager). orcad conforms to it: + +- **Readiness.** One JSON line on stdout (`--json`), `type: "orca_server_ready"`, published + after the listener is bound and the daemon verdict is in. There is no separate readiness + socket; the line is the signal. Set the supervisor's start timeout generously — the daemon + launch has its own retries and can take tens of seconds on a cold host. +- **Shutdown.** `SIGTERM` or `SIGINT` starts a graceful stop. A **second** signal exits + immediately with code 1 rather than being swallowed — a supervisor's second signal means + its first deadline elapsed, and waiting silently is what turns a stop into a `SIGKILL`, + the one teardown that skips the daemon handoff. orcad also imposes its own 15s deadline + and exits 1, so the failure stays attributable instead of arriving as an unlogged kill. +- **Exit codes.** + + | Code | Meaning | Supervisor should | + | --- | --- | --- | + | 0 | clean shutdown | restart per policy | + | 1 | startup or shutdown failure | restart with backoff | + | 78 | configuration fault (bind address, data root, instance lock) | **not** restart | + + 78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data + root owned by someone else is a restart-spin, not a recovery. +- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract + to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes + its own NDJSON lifecycle log to `/logs/daemon.log` (suppressed by + `ORCA_DIAGNOSTICS_DISABLED=1`). Rotation of that file is not implemented — see + [What is not covered](#what-is-not-covered). + +### orcad supervising the daemon + +- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID + record, token and socket under `/daemon`. +- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not + replaced, unless it is unhealthy, foreign, or built from a superseded bundle *and* owns no + live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers + to live work. +- **Restart.** The adapter respawns the daemon on death, transparently to callers. +- **Crash-loop containment.** At most **5 launches per 60s rolling window** per orcad run; + past that, launches are refused with `daemon_crash_loop` and terminals fail with that + message instead of the process forking forever. The window slides, so a repaired host + recovers without restarting orcad. An operator-initiated daemon restart clears it — that + is the deliberate "try again". +- **No macOS login-session watch.** That watch retires the daemon when the spawning GUI login + session dies. An orcad daemon must survive its SSH session ending. +- **Shutdown.** orcad never stops the daemon. A daemon that was never adopted retires itself + after its adoption window; an adopted one stays resident (see Decommissioning). + +### Decommissioning + +The daemon outliving orcad is deliberate, so stopping orcad does **not** leave the host with +zero Orca processes. A daemon that has been adopted stays resident after its runtime +disconnects — that is what makes the next start a reattach rather than a cold restore. To +retire a host completely, stop orcad and then stop the daemon named by +`health.terminalDaemon.pid`, or delete the data root and let the endpoint go stale. + +## Health + +The readiness payload carries a `health` object: + +``` +buildHash sha256 (16 hex) of the running orcad bundle — build identity that a version + string cannot give, so a rollback that did not replace the file is visible +buildVersion ORCA_VERSION +nodeVersion / nodeAbi process.versions.node / .modules — the ABI native addons must match +platform / arch / pid +terminalDaemon: + state live | degraded | absent + ownsFreshSessions whether NEW terminals are daemon-owned, i.e. survive an orcad restart + pid the live daemon's pid, from its own PID record + buildVersion the build the LIVE daemon was forked from (may legitimately predate + this orcad after an update — reporting orcad's version for both would + hide exactly that) + entryPath / protocolVersion + selfTest { ok, coverage, verdict, durationMs } +``` + +### What the self-test proves + +`selfTest` runs `checkDaemonHealth` against the daemon's socket. It is green only when the +daemon **opened its socket, completed the protocol handshake, and ran `ptySpawnHealth` — a +real short-lived PTY spawned inside the daemon's own process**. It therefore spans both +processes: orcad drives it, the daemon performs it, the verdict crosses the socket. + +- `coverage: 'pty-spawn'` — the full round trip above. +- `coverage: 'handshake'` — **win32 only**, where `checkPtySpawnHealth` returns without + spawning anything. A green verdict there covers the handshake and nothing more. It is + reported separately rather than folded into `ok` so nobody reads it as a PTY round trip. + +`state` is `live` only when the self-test passed **and** `ownsFreshSessions` is true. A +daemon that answers but has fallen back to local spawning for new terminals is `degraded`, +because those terminals die with orcad. A daemon that answered and then failed its spawn +probe is also `degraded`, not `absent`: it still holds live sessions, and calling those +exited would be the verdict `ssh-execution-boundary.md` forbids guessing. + +## What is not covered + +Named here so nothing reads as implemented that is not: + +- **A continuous health endpoint.** `health` is published once, in the readiness payload. A + supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same + `collectOrcadHealth()`; that surface does not exist yet. +- **libc slot.** §04 asks for it in the health payload. It belongs to the native strategy + (plan item 5), which owns libc detection; there is no honest value to publish until then. +- **`degradations[]`.** Plan item 2's contract, not this one. +- **Credential administration** (list / revoke / rotate devices, expiring pending offers, + structured security logging) — §04, not delivered here. +- **Pinned-port fail-closed.** A pinned `--port` still falls back to an OS-assigned port on + conflict. +- **Reconciling `webClientUrl` with reachability** under the loopback default. +- **State-schema rollback rules.** +- **Daemon log rotation.** `/logs/daemon.log` grows unbounded. diff --git a/package.json b/package.json index b87bb21a49a..7b46b369406 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", "build:orcad": "node config/scripts/build-orcad.mjs", + "build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs", "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad", "smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", diff --git a/src/main/daemon/daemon-entry-path-layouts.test.ts b/src/main/daemon/daemon-entry-path-layouts.test.ts new file mode 100644 index 00000000000..b960cbf9d8f --- /dev/null +++ b/src/main/daemon/daemon-entry-path-layouts.test.ts @@ -0,0 +1,115 @@ +/** + * Which daemon-entry.js the launcher forks, per deployment layout. + * + * The layout that matters here is orcad's: a packaged host with NO asar, whose bundle root + * holds `orcad.js` and `daemon-entry.js` side by side (config/scripts/build-orcad.mjs emits + * exactly that). Resolving against `out/main` there would fork a path that does not exist, + * and the failure would surface as "terminals do not persist" rather than as a missing file. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { FAKE_APP_PATH, FAKE_DAEMON_ENTRY_PATH } from './daemon-init-test-harness' + +const { + getAppPathMock, + isPackagedMock, + probeSocketExistsMock, + forkMock, + checkDaemonHealthMock, + spawnerInstances, + importFresh, + installDefaultNetConnectStub, + moduleFactories +} = await vi.hoisted(async () => + (await import('./daemon-init-test-harness')).createDaemonInitMocks() +) + +vi.mock('fs', () => moduleFactories.fs()) +vi.mock('child_process', async (importOriginal) => + moduleFactories.childProcess(await importOriginal>()) +) +vi.mock('net', () => moduleFactories.net()) +vi.mock('./daemon-health', () => moduleFactories.daemonHealth()) +vi.mock('./daemon-pid-identity', () => moduleFactories.daemonPidIdentity()) +vi.mock('./daemon-tcc-attribution', () => moduleFactories.daemonTccAttribution()) +vi.mock('./daemon-bundle-staleness', () => moduleFactories.daemonBundleStaleness()) +vi.mock('./daemon-stale-kill', () => moduleFactories.daemonStaleKill()) +vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartTime()) +vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse()) +vi.mock('./client', () => moduleFactories.client()) +vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent()) +vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner()) +vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter()) +vi.mock('../ipc/pty', () => moduleFactories.ipcPty()) + +const ASAR_APP_PATH = join('/packaged', 'resources', 'app.asar') +const ASAR_UNPACKED_ENTRY = join( + '/packaged', + 'resources', + 'app.asar.unpacked', + 'out', + 'main', + 'daemon-entry.js' +) +const ORCAD_ROOT = join('/opt', 'orcad') +const ORCAD_ADJACENT_ENTRY = join(ORCAD_ROOT, 'daemon-entry.js') + +/** Drive one launch under the given layout and return the entry path that was forked. */ +async function forkedDaemonEntryPath(layout: { + appPath: string + isPackaged: boolean + existingEntry?: string +}): Promise { + // Why after importFresh: it resets these mocks to their defaults on every fresh import. + const mod = await importFresh() + getAppPathMock.mockReturnValue(layout.appPath) + isPackagedMock.mockReturnValue(layout.isPackaged) + probeSocketExistsMock.mockImplementation((p?: string) => p === layout.existingEntry) + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise + forkMock.mockImplementationOnce(() => { + throw new Error('stop after entry resolution') + }) + await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( + 'stop after entry resolution' + ) + return forkMock.mock.calls.at(-1)?.[0] as string +} + +describe('daemon entry path per deployment layout', () => { + beforeEach(() => { + installDefaultNetConnectStub() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('resolves the dev checkout entry under out/main', async () => { + expect(await forkedDaemonEntryPath({ appPath: FAKE_APP_PATH, isPackaged: false })).toBe( + FAKE_DAEMON_ENTRY_PATH + ) + }) + + it('redirects a packaged Electron asar root to app.asar.unpacked', async () => { + expect(await forkedDaemonEntryPath({ appPath: ASAR_APP_PATH, isPackaged: true })).toBe( + ASAR_UNPACKED_ENTRY + ) + }) + + it('forks the entry beside orcad.js on a packaged host with no asar', async () => { + // orcad answers isPackaged() true; the question the resolver must ask is whether the app + // root is an asar archive, not whether the build is packaged. + expect( + await forkedDaemonEntryPath({ + appPath: ORCAD_ROOT, + isPackaged: true, + existingEntry: ORCAD_ADJACENT_ENTRY + }) + ).toBe(ORCAD_ADJACENT_ENTRY) + }) +}) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index addfa50b16a..72537f64cbf 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -327,8 +327,9 @@ async function main(): Promise { warmWindowsConptyOnce() } -// Only auto-run when executed directly (not imported for testing) -const isDirectExecution = !process.env.VITEST +// Only auto-run when executed directly (not imported for testing, or for the build guard's +// load check — see config/scripts/build-orcad.mjs). +const isDirectExecution = !process.env.VITEST && !process.env.ORCA_DAEMON_ENTRY_LOAD_CHECK if (isDirectExecution) { main().catch((err) => { console.error('[daemon] Fatal:', err) diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index 1d3ab98db90..78e4787c0f6 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -9,20 +9,30 @@ import { } from 'node:fs' import os from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' -// Mutable Electron app stub, hoisted so the vi.mock factory closes over it. -const { electronApp } = vi.hoisted(() => ({ - electronApp: { - isPackaged: true, - userDataPath: '', - version: '9.9.9', - getPath: (): string => electronApp.userDataPath, - getVersion: (): string => electronApp.version - } -})) +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' -vi.mock('electron', () => ({ app: electronApp })) +// Mutable host stub. Relocation now reads the AppEnvironment port rather than electron's +// `app`, so orcad's daemon launch path can resolve without Electron in the graph. +const hostApp = { + isPackaged: true, + userDataPath: '', + appPath: '', + version: '9.9.9' +} + +function installHostApp(): void { + setAppEnvironment({ + getPath: () => hostApp.userDataPath, + getAppPath: () => hostApp.appPath, + getVersion: () => hostApp.version, + isPackaged: () => hostApp.isPackaged, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + } as AppEnvironment) +} import { buildDaemonHostManifest, @@ -88,9 +98,11 @@ beforeEach(() => { mkdirSync(localAppDataDir, { recursive: true }) process.env.LOCALAPPDATA = localAppDataDir buildInstallFixture(installDir) - electronApp.isPackaged = true - electronApp.userDataPath = userDataDir - electronApp.version = '9.9.9' + hostApp.isPackaged = true + hostApp.userDataPath = userDataDir + hostApp.appPath = join(installDir, 'resources', 'app.asar') + hostApp.version = '9.9.9' + installHostApp() setProcessProp('platform', 'win32') setProcessProp('execPath', join(installDir, 'Orca.exe')) setProcessProp('resourcesPath', join(installDir, 'resources')) @@ -214,6 +226,18 @@ describe('materializeRelocatedDaemonHost', () => { expect(materializeRelocatedDaemonHost()).toBeNull() expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) }) + + it('does nothing for a packaged host with no asar root (orcad on win32)', () => { + // orcad answers isPackaged() true — it is a shipped build — but it is plain Node: no + // asar, no resourcesPath, and no NSIS updater to escape. Relocation staging a copy of + // an Electron tree that is not there is the isPackaged-honesty defect, and it would + // silently produce a null host on a path whose failures are meant to be visible. + hostApp.appPath = join(installDir, 'resources', 'app') + installHostApp() + expect(materializeRelocatedDaemonHost()).toBeNull() + expect(getRelocatedDaemonHost()).toBeNull() + expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) + }) }) describe('getRelocatedDaemonHost', () => { @@ -249,6 +273,17 @@ describe('pruneOldDaemonHosts', () => { expect(existsSync(join(root, '2.0.0'))).toBe(true) expect(existsSync(join(root, '1.0.0'))).toBe(false) }) + + it('reclaims nothing for a packaged host with no asar root (orcad on win32)', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + hostApp.appPath = join(installDir, 'resources', 'app') + installHostApp() + pruneOldDaemonHosts(new Set()) + // A Node host owns no daemon-host tree, so deleting under it would be reaching into a + // directory layout it never created. + expect(existsSync(join(root, '1.0.0'))).toBe(true) + }) }) describe('collectPinnedDaemonVersions', () => { diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 4242b292de1..79e8bcf0ad7 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -10,7 +10,7 @@ import { writeFileSync } from 'node:fs' import { dirname, join, win32 as winPath } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { parseDaemonPidFile } from './daemon-pid-file-parse' import { startTimeMatches } from './daemon-process-start-time' @@ -85,9 +85,27 @@ function resolveEntrySourcePath(resourcesPath: string): string { return join(unpackedRoot, 'out', 'main', 'daemon-entry.js') } +/** + * Whether this process is a packaged ELECTRON app on win32 — the only shape relocation + * addresses, because what it escapes is the NSIS updater's kill zone. + * + * Why asar and not isPackaged alone: orcad answers isPackaged() true (it is a shipped build, + * not a dev checkout) while having no asar, no resourcesPath and no NSIS installer. Asking + * whether the app root is an asar archive is the same honesty fix the watcher path uses, and + * it keeps a Node host from staging a copy of an Electron tree it does not have. + */ +function isPackagedElectronWin32(): boolean { + const environment = getAppEnvironment() + return ( + process.platform === 'win32' && + environment.isPackaged() && + environment.getAppPath().includes('app.asar') + ) +} + // Relocation inputs from the live packaged process, or null when it doesn't apply (non-win32, dev, or missing resourcesPath). function collectDaemonHostSources(): DaemonHostSources | null { - if (process.platform !== 'win32' || !app.isPackaged) { + if (!isPackagedElectronWin32()) { return null } const resourcesPath = process.resourcesPath @@ -206,7 +224,7 @@ function hostRootDir(): string { const base = typeof localAppData === 'string' && localAppData.length > 0 ? join(localAppData, LOCAL_HOST_ROOT_NAME) - : app.getPath('userData') + : getAppEnvironment().getPath('userData') return join(base, HOST_SUBDIR) } @@ -219,7 +237,7 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { if (!sources) { return null } - const version = app.getVersion() + const version = getAppEnvironment().getVersion() const dest = join(hostRootDir(), version) const marker = readMarker(dest) if (!marker || marker.version !== version) { @@ -246,7 +264,7 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { if (!sources) { return null } - const version = app.getVersion() + const version = getAppEnvironment().getVersion() const root = hostRootDir() const dest = join(root, version) const staging = join(root, `${version}.staging-${randomBytes(6).toString('hex')}`) @@ -319,10 +337,10 @@ export function collectPinnedDaemonVersions(runtimeDir: string): Set { * Best-effort — never throws; a locked/staging dir is retried on a future launch. */ export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { - if (process.platform !== 'win32' || !app.isPackaged) { + if (!isPackagedElectronWin32()) { return } - const version = app.getVersion() + const version = getAppEnvironment().getVersion() const root = hostRootDir() let entries try { diff --git a/src/main/daemon/daemon-init-child-readiness.test.ts b/src/main/daemon/daemon-init-child-readiness.test.ts index 132727fff5f..60d3c9c596d 100644 --- a/src/main/daemon/daemon-init-child-readiness.test.ts +++ b/src/main/daemon/daemon-init-child-readiness.test.ts @@ -16,7 +16,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-child-startup-failure.test.ts b/src/main/daemon/daemon-init-child-startup-failure.test.ts index 182307d075b..27d23a400d2 100644 --- a/src/main/daemon/daemon-init-child-startup-failure.test.ts +++ b/src/main/daemon/daemon-init-child-startup-failure.test.ts @@ -14,7 +14,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index 40e7ffa1ae6..d920a13866e 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -17,10 +17,7 @@ export type { MockAdapter, MockSpawner } from './daemon-init-mock-types' /** The module objects each test file's own hoisted `vi.mock` factories return. */ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { const { - getPathMock, - getAppPathMock, forkMock, - isPackagedMock, probeSocketExistsMock, writeFileSyncMock, readFileSyncMock, @@ -62,6 +59,7 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { readonly launcher: unknown readonly ensureRunning: Mock readonly resetHandle: Mock + readonly resetRespawnWindow: Mock readonly shutdown: Mock readonly getHandle: Mock private socketCounter: number @@ -99,6 +97,7 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { } }) this.resetHandle = vi.fn() + this.resetRespawnWindow = vi.fn() this.shutdown = vi.fn(async () => {}) this.getHandle = vi.fn(() => this.handle) spawnerInstances.push(this as unknown as MockSpawner) @@ -159,16 +158,6 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { } return { - electron: () => ({ - app: { - get isPackaged() { - return isPackagedMock() - }, - getPath: getPathMock, - getAppPath: getAppPathMock, - getVersion: () => '1.2.3' - } - }), fs: () => ({ mkdirSync: vi.fn<(...args: unknown[]) => void>(), existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'), diff --git a/src/main/daemon/daemon-init-endpoint-adoption.test.ts b/src/main/daemon/daemon-init-endpoint-adoption.test.ts index 2566ba0996e..5503188d430 100644 --- a/src/main/daemon/daemon-init-endpoint-adoption.test.ts +++ b/src/main/daemon/daemon-init-endpoint-adoption.test.ts @@ -25,7 +25,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-fresh-import.ts b/src/main/daemon/daemon-init-fresh-import.ts index 5515e5d213e..1d4f35d655c 100644 --- a/src/main/daemon/daemon-init-fresh-import.ts +++ b/src/main/daemon/daemon-init-fresh-import.ts @@ -1,9 +1,11 @@ import { vi } from 'vitest' +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import type { DaemonInitMockState } from './daemon-init-test-harness' /** Resets every mock plus the module registry, then re-imports daemon-init so its module-level spawner/adapter/restartInFlight start fresh. */ export async function importFreshDaemonInit(state: DaemonInitMockState) { const { + getPathMock, getAppPathMock, isPackagedMock, probeSocketExistsMock, @@ -108,6 +110,17 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { launchedStartedAtMs.current = 1_000_000 getProcessStartedAtMsMock.mockReset() getProcessStartedAtMsMock.mockReturnValue(1_000_000) + // Why the real port rather than a module mock: daemon-init reads AppEnvironment, whose + // installed instance is anchored to a realm symbol precisely so it survives resetModules. + setAppEnvironment({ + getPath: getPathMock, + getAppPath: getAppPathMock, + getVersion: () => '1.2.3', + isPackaged: isPackagedMock, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + } as unknown as AppEnvironment) // Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer. return import('./daemon-init') } diff --git a/src/main/daemon/daemon-init-live-session-preservation.test.ts b/src/main/daemon/daemon-init-live-session-preservation.test.ts index 06c926386b9..2a3eece9937 100644 --- a/src/main/daemon/daemon-init-live-session-preservation.test.ts +++ b/src/main/daemon/daemon-init-live-session-preservation.test.ts @@ -23,7 +23,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index c71c19d7e4f..8c8b805740f 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -4,6 +4,7 @@ import type { Mock } from 'vitest' export type MockSpawner = { ensureRunning: Mock resetHandle: Mock + resetRespawnWindow: Mock shutdown: Mock getHandle: Mock launcher: unknown diff --git a/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts b/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts index f3ee88e7b70..6683e627e6d 100644 --- a/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts +++ b/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts @@ -24,7 +24,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index e8038894d69..ceb7e9dfa69 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -28,7 +28,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) @@ -255,6 +254,28 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(adapterInstances[0].listProcesses).toHaveBeenCalled() }) + it('answers daemonOwnsFreshPersistentPtys honestly for each installed provider', async () => { + // What orcad publishes as `canRecoverPersistentLocalPtys` and as the readiness health + // verdict. Answering true under degraded routing would advertise recovery for terminals + // that die with the runtime process. + const mod = await importFresh() + expect(mod.daemonOwnsFreshPersistentPtys()).toBe(false) + + await mod.initDaemonPtyProvider() + expect(mod.daemonOwnsFreshPersistentPtys()).toBe(true) + + const degraded = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/degraded-socket', + tokenPath: '/fake/degraded-token', + mode: 'degraded-new-pty-fallback' + })) + await degraded.initDaemonPtyProvider() + const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') + expect(degraded.getDaemonProvider()).toBeInstanceOf(DegradedDaemonPtyProvider) + expect(degraded.daemonOwnsFreshPersistentPtys()).toBe(false) + }) + it('rechecks the preserved daemon endpoint before recovering fresh-spawn routing', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ diff --git a/src/main/daemon/daemon-init-replacement-reporting.test.ts b/src/main/daemon/daemon-init-replacement-reporting.test.ts index 5cc3f33a02a..92bed823fb3 100644 --- a/src/main/daemon/daemon-init-replacement-reporting.test.ts +++ b/src/main/daemon/daemon-init-replacement-reporting.test.ts @@ -26,7 +26,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-restart-sequence.test.ts b/src/main/daemon/daemon-init-restart-sequence.test.ts index 5f1468342ac..71417db986c 100644 --- a/src/main/daemon/daemon-init-restart-sequence.test.ts +++ b/src/main/daemon/daemon-init-restart-sequence.test.ts @@ -24,7 +24,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts b/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts index 7ad1b96abb7..18debd1342a 100644 --- a/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts +++ b/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts @@ -18,7 +18,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index d6c4240b19b..77edbed6027 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -2,7 +2,7 @@ restart, teardown); the "swap the provider atomically" invariant keeps restart + singletons co-located. */ import { join } from 'node:path' import { randomUUID } from 'node:crypto' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs' import { fork, type ChildProcess } from 'node:child_process' import { connect } from 'node:net' @@ -36,7 +36,7 @@ import { import { getDaemonLaunchIdentity } from './daemon-pid-identity' import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness' import { killStaleDaemon } from './daemon-stale-kill' -import { parseDaemonPidFile } from './daemon-pid-file-parse' +import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' import { collectPinnedDaemonVersions, materializeRelocatedDaemonHost, @@ -102,21 +102,25 @@ let adapter: DaemonProvider | null = null let restartInFlight: Promise | null = null function getRuntimeDir(): string { - const dir = join(app.getPath('userData'), 'daemon') + const dir = join(getAppEnvironment().getPath('userData'), 'daemon') mkdirSync(dir, { recursive: true }) return dir } function getHistoryDir(): string { - const dir = join(app.getPath('userData'), 'terminal-history') + const dir = join(getAppEnvironment().getPath('userData'), 'terminal-history') mkdirSync(dir, { recursive: true }) return dir } function getDaemonEntryPath(): string { - const appPath = app.getAppPath() - // Why: packaged app.getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable. - const basePath = app.isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const appPath = getAppEnvironment().getAppPath() + // Why: packaged getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable. + // Why asar and not isPackaged: orcad is a packaged non-Electron host whose bundle root holds + // orcad.js and daemon-entry.js side by side with no asar to redirect (see parcel-watcher-entry-path.ts). + const basePath = appPath.includes('app.asar') + ? appPath.replace('app.asar', 'app.asar.unpacked') + : appPath const directEntryPath = join(basePath, 'daemon-entry.js') if (existsSync(directEntryPath)) { return directEntryPath @@ -124,6 +128,12 @@ function getDaemonEntryPath(): string { return join(basePath, 'out', 'main', 'daemon-entry.js') } +// macOS TCC attribution pins the daemon to a packaged app bundle; there is none on a Node host. +function resolvePackagedDarwinAppVersion(): string | null { + const environment = getAppEnvironment() + return process.platform === 'darwin' && environment.isPackaged() ? environment.getVersion() : null +} + // Why: pass a log-file arg so field failures are diagnosable, but honor the ORCA_DIAGNOSTICS_DISABLED privacy switch. function daemonLogArgs(): string[] { const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase() @@ -519,12 +529,12 @@ function createOutOfProcessLauncher( entryPath ) const stalePackagedBundle = - app.isPackaged && + getAppEnvironment().isPackaged() && (await isDaemonStaleForCurrentBundle( runtimeDir, socketPath, tokenPath, - app.getVersion() + getAppEnvironment().getVersion() )) if (identity === 'mismatch' || stalePackagedBundle) { // Why: replacing a healthy daemon kills its child PTYs; defer code freshness until no live sessions would be lost. @@ -662,7 +672,7 @@ function createOutOfProcessLauncher( trackDaemonReplaced(pendingReplacement.reason, pendingReplacement.liveSessionCount) } - const userDataPath = app.getPath('userData') + const userDataPath = getAppEnvironment().getPath('userData') // Why: on win32 packaged, stage a daemon-host copy in userData so its image escapes the NSIS updater's kill zone; lazy so it's off first-paint. Fail-open: null → in-dir host. const relocatedHost = materializeRelocatedDaemonHost() // Fork the relocated entry when available; otherwise the install-dir entry. @@ -681,7 +691,7 @@ function createOutOfProcessLauncher( '--entry-path', entryPath, '--app-version', - app.getVersion(), + getAppEnvironment().getVersion(), '--spawner-exec-path', process.execPath, ...(macosLoginSessionWatch ? ['--login-session-watch'] : []), @@ -699,7 +709,7 @@ function createOutOfProcessLauncher( env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', - // Why: the detached plain-Node daemon can't call app.getPath(), but shell rcfiles must live outside swept tmp. + // Why: the detached plain-Node daemon has no AppEnvironment, but shell rcfiles must live outside swept tmp. ORCA_USER_DATA_PATH: userDataPath } } @@ -962,7 +972,7 @@ export async function initDaemonPtyProvider( pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir, - packagedAppVersion: process.platform === 'darwin' && app.isPackaged ? app.getVersion() : null, + packagedAppVersion: resolvePackagedDarwinAppVersion(), historyPath: getHistoryDir(), // Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying. respawn: async (reason: DaemonRespawnReason) => { @@ -1066,6 +1076,60 @@ async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise< } // Why: a narrow getter (not a raw export) keeps the "swap on restart" invariant in one place (replaceDaemonProvider). +/** + * Whether the installed provider is a daemon that will own FRESH terminals too. + * + * Why not `getDaemonProvider() !== null`: DegradedDaemonPtyProvider routes the daemon's + * EXISTING sessions to the daemon but spawns new ones on the in-process local provider, so + * those die with this process. A host that answered "I can recover persistent local PTYs" + * from that state would be advertising recovery for terminals that cannot be recovered. + */ +export function daemonOwnsFreshPersistentPtys(): boolean { + return adapter !== null && !(adapter instanceof DegradedDaemonPtyProvider) +} + +/** Endpoint coordinates of the daemon this process installed, for out-of-band health probes. */ +export type DaemonEndpointFacts = { + runtimeDir: string + socketPath: string + tokenPath: string + pidPath: string + protocolVersion: number +} + +export function getDaemonEndpointFacts(): DaemonEndpointFacts | null { + if (!adapter) { + return null + } + const runtimeDir = getRuntimeDir() + return { + runtimeDir, + socketPath: getDaemonSocketPath(runtimeDir), + tokenPath: getDaemonTokenPath(runtimeDir), + pidPath: getDaemonPidPath(runtimeDir), + protocolVersion: PROTOCOL_VERSION + } +} + +/** + * What the live daemon's own PID record says about the build it was forked from. + * + * Why the record and not this process's version: the daemon deliberately outlives the + * runtime, so after an update the two can legitimately disagree — and a health surface that + * reported orcad's version for both would hide exactly that. + */ +export function readDaemonPidRecord(): ParsedDaemonPid | null { + const facts = getDaemonEndpointFacts() + if (!facts) { + return null + } + try { + return parseDaemonPidFile(readFileSync(facts.pidPath, 'utf8')) + } catch { + return null + } +} + export function getDaemonProvider(): DaemonProvider | null { return adapter } @@ -1154,6 +1218,9 @@ async function runRestartDaemon(): Promise { } const runtimeDir = getRuntimeDir() + // An operator asking for a restart is the deliberate "try again" that clears crash-loop + // containment; without this a wedged host could never be recovered from the UI. + currentSpawner.resetRespawnWindow() const currentOnly = getCurrentDaemonAdapter(currentAdapter) const legacyAdapters = getLegacyDaemonAdapters(currentAdapter) @@ -1198,7 +1265,7 @@ async function runRestartDaemon(): Promise { pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir, - packagedAppVersion: process.platform === 'darwin' && app.isPackaged ? app.getVersion() : null, + packagedAppVersion: resolvePackagedDarwinAppVersion(), historyPath: getHistoryDir(), respawn: async (reason: DaemonRespawnReason) => { // Why: attribute rather than emit — the launcher below is the one that completes the diff --git a/src/main/daemon/daemon-respawn-throttle.test.ts b/src/main/daemon/daemon-respawn-throttle.test.ts new file mode 100644 index 00000000000..70806f999e8 --- /dev/null +++ b/src/main/daemon/daemon-respawn-throttle.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest' +import { DaemonSpawner, type DaemonProcessHandle } from './daemon-spawner' +import { DaemonCrashLoopError, DaemonRespawnThrottle } from './daemon-respawn-throttle' + +describe('DaemonRespawnThrottle', () => { + it('admits up to the cap, then refuses with the time left in the window', () => { + let now = 0 + const throttle = new DaemonRespawnThrottle({ + maxAttempts: 3, + windowMs: 1_000, + now: () => now + }) + expect(throttle.admit().allowed).toBe(true) + now = 200 + expect(throttle.admit().allowed).toBe(true) + now = 400 + expect(throttle.admit().allowed).toBe(true) + now = 500 + const refused = throttle.admit() + expect(refused).toEqual({ + allowed: false, + reason: 'crash_loop', + attemptsInWindow: 3, + retryAfterMs: 500 + }) + }) + + it('admits again once the window slides past the oldest attempt', () => { + let now = 0 + const throttle = new DaemonRespawnThrottle({ maxAttempts: 2, windowMs: 1_000, now: () => now }) + throttle.admit() + throttle.admit() + expect(throttle.admit().allowed).toBe(false) + now = 1_500 + expect(throttle.admit().allowed).toBe(true) + }) + + it('clears the window on an explicit reset (the operator-restart escape hatch)', () => { + const throttle = new DaemonRespawnThrottle({ maxAttempts: 1, windowMs: 60_000 }) + expect(throttle.admit().allowed).toBe(true) + expect(throttle.admit().allowed).toBe(false) + throttle.reset() + expect(throttle.admit().allowed).toBe(true) + }) +}) + +describe('DaemonSpawner crash-loop containment', () => { + const handle: DaemonProcessHandle = { shutdown: async () => {} } + + it('stops forking once the daemon has died repeatedly inside the window', async () => { + const launcher = vi.fn(async () => handle) + const spawner = new DaemonSpawner({ + runtimeDir: '/tmp/orcad-throttle-test', + launcher, + respawnThrottle: new DaemonRespawnThrottle({ maxAttempts: 3, windowMs: 60_000 }) + }) + for (let i = 0; i < 3; i += 1) { + await spawner.ensureRunning() + // What a dead daemon looks like to the adapter's respawn path. + spawner.resetHandle() + } + expect(launcher).toHaveBeenCalledTimes(3) + await expect(spawner.ensureRunning()).rejects.toBeInstanceOf(DaemonCrashLoopError) + // The refusal must actually prevent the fork, not just annotate it. + expect(launcher).toHaveBeenCalledTimes(3) + }) + + it('does not count a cached handle as a new attempt', async () => { + const launcher = vi.fn(async () => handle) + const spawner = new DaemonSpawner({ + runtimeDir: '/tmp/orcad-throttle-test', + launcher, + respawnThrottle: new DaemonRespawnThrottle({ maxAttempts: 2, windowMs: 60_000 }) + }) + await spawner.ensureRunning() + await spawner.ensureRunning() + await spawner.ensureRunning() + expect(launcher).toHaveBeenCalledTimes(1) + }) + + it('lets an operator restart clear containment', async () => { + const launcher = vi.fn(async () => handle) + const spawner = new DaemonSpawner({ + runtimeDir: '/tmp/orcad-throttle-test', + launcher, + respawnThrottle: new DaemonRespawnThrottle({ maxAttempts: 1, windowMs: 60_000 }) + }) + await spawner.ensureRunning() + spawner.resetHandle() + await expect(spawner.ensureRunning()).rejects.toBeInstanceOf(DaemonCrashLoopError) + spawner.resetRespawnWindow() + await expect(spawner.ensureRunning()).resolves.toBeDefined() + expect(launcher).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/daemon/daemon-respawn-throttle.ts b/src/main/daemon/daemon-respawn-throttle.ts new file mode 100644 index 00000000000..9988a6b9fac --- /dev/null +++ b/src/main/daemon/daemon-respawn-throttle.ts @@ -0,0 +1,84 @@ +/** + * Crash-loop containment for the terminal daemon. + * + * The respawn path is driven by `ensureConnected()`: every reconnect attempt against a dead + * socket forks a replacement. A daemon that dies during startup — a broken node-pty, an + * unwritable runtime dir, a wrong libc — therefore forks forever, as fast as the caller + * retries. On the desktop that burns CPU; under orcad, where a supervisor is watching a + * process that never reports failure, it is the "restart-spin while the deploy reports + * success" shape the ops contract has to rule out. + * + * Sliding window, not a permanent trip: the failure is usually environmental, and an + * environment can be repaired without restarting the runtime. Once the window drains, the + * next attempt is admitted and a repaired host recovers on its own. + */ +export type DaemonRespawnAdmission = + | { allowed: true } + | { allowed: false; reason: 'crash_loop'; attemptsInWindow: number; retryAfterMs: number } + +export type DaemonRespawnThrottleOptions = { + /** Attempts allowed inside `windowMs` before the next one is refused. */ + maxAttempts?: number + windowMs?: number + now?: () => number +} + +// Generous on purpose: a user manually restarting the daemon a few times, or a laptop +// waking to a stale socket, must never trip this. Five failures inside a minute is a +// daemon that cannot start, not a daemon having a bad moment. +export const DEFAULT_DAEMON_RESPAWN_MAX_ATTEMPTS = 5 +export const DEFAULT_DAEMON_RESPAWN_WINDOW_MS = 60_000 + +export class DaemonRespawnThrottle { + private readonly maxAttempts: number + private readonly windowMs: number + private readonly now: () => number + private attempts: number[] = [] + + constructor(options: DaemonRespawnThrottleOptions = {}) { + this.maxAttempts = options.maxAttempts ?? DEFAULT_DAEMON_RESPAWN_MAX_ATTEMPTS + this.windowMs = options.windowMs ?? DEFAULT_DAEMON_RESPAWN_WINDOW_MS + this.now = options.now ?? Date.now + } + + /** Record and admit one respawn attempt, or refuse it as a crash loop. */ + admit(): DaemonRespawnAdmission { + const now = this.now() + this.attempts = this.attempts.filter((at) => now - at < this.windowMs) + if (this.attempts.length >= this.maxAttempts) { + const oldest = this.attempts[0] as number + return { + allowed: false, + reason: 'crash_loop', + attemptsInWindow: this.attempts.length, + retryAfterMs: Math.max(0, this.windowMs - (now - oldest)) + } + } + this.attempts.push(now) + return { allowed: true } + } + + /** + * Forget the recorded attempts. + * + * Why not automatic on a successful fork: a crash loop IS a sequence of successful forks + * followed by immediate deaths, so "the fork returned" is not evidence of recovery. Only + * a caller that knows the daemon stayed up — or a deliberate operator restart — may clear + * the window. + */ + reset(): void { + this.attempts = [] + } +} + +export class DaemonCrashLoopError extends Error { + readonly code = 'daemon_crash_loop' + constructor(admission: Extract) { + super( + `The terminal daemon has failed ${admission.attemptsInWindow} times in a row; refusing to ` + + `respawn it for another ${Math.ceil(admission.retryAfterMs / 1000)}s. Terminals will not ` + + 'start until the underlying failure is fixed (check the daemon log).' + ) + this.name = 'DaemonCrashLoopError' + } +} diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 6a6d377cc2f..a0376ef0fc0 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -10,6 +10,7 @@ import { } from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' +import { DaemonCrashLoopError, DaemonRespawnThrottle } from './daemon-respawn-throttle' export type DaemonConnectionInfo = { socketPath: string @@ -44,6 +45,8 @@ export type DaemonLauncher = ( export type DaemonSpawnerOptions = { runtimeDir: string launcher: DaemonLauncher + /** Test seam; production uses the default window. */ + respawnThrottle?: DaemonRespawnThrottle } export class DaemonSpawner { @@ -53,10 +56,12 @@ export class DaemonSpawner { private socketPath: string private tokenPath: string private pidPath: string + private respawnThrottle: DaemonRespawnThrottle constructor(opts: DaemonSpawnerOptions) { this.runtimeDir = opts.runtimeDir this.launcher = opts.launcher + this.respawnThrottle = opts.respawnThrottle ?? new DaemonRespawnThrottle() this.socketPath = getDaemonSocketPath(this.runtimeDir) this.tokenPath = getDaemonTokenPath(this.runtimeDir) this.pidPath = getDaemonPidPath(this.runtimeDir) @@ -67,6 +72,14 @@ export class DaemonSpawner { return { socketPath: this.socketPath, tokenPath: this.tokenPath } } + // Why here and not in the respawn callback: every launch — first, post-death, and + // post-restart — funnels through this method, so this is the only place a crash loop + // cannot route around. + const admission = this.respawnThrottle.admit() + if (!admission.allowed) { + throw new DaemonCrashLoopError(admission) + } + // Why: a detached daemon may clean up after its parent exits. A unique // launch identity keeps it from deleting a replacement daemon's PID file. this.handle = await this.launcher(this.socketPath, this.tokenPath, this.pidPath, randomUUID()) @@ -85,6 +98,17 @@ export class DaemonSpawner { this.handle = null } + /** + * Forget the crash-loop window. + * + * Why an explicit call and not "a fork succeeded": a crash loop is a run of successful + * forks whose daemons then die, so the fork returning proves nothing. An operator asking + * for a restart does mean "try again", and that is the only thing that clears it. + */ + resetRespawnWindow(): void { + this.respawnThrottle.reset() + } + async shutdown(): Promise { if (!this.handle) { return diff --git a/src/main/observability/logs-directory.ts b/src/main/observability/logs-directory.ts index 94a1b7baae8..20ad104af74 100644 --- a/src/main/observability/logs-directory.ts +++ b/src/main/observability/logs-directory.ts @@ -1,27 +1,29 @@ // Single source of truth for the app's logs directory and the files inside it. // macOS convention is `~/Library/Application Support/Orca/logs/`; Windows and -// Linux resolve the same intent via Electron's `userData` dir. Falls back to a -// homedir-derived path when Electron's `app` is unavailable (unit tests). +// Linux resolve the same intent via the host's `userData` dir. Falls back to a +// homedir-derived path when no AppEnvironment is installed (unit tests). -import { app } from 'electron' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { homedir, platform } from 'node:os' import { join } from 'node:path' +// Why the port and not electron's `app`: the daemon launch path reads this for --log-file, +// and that path has to resolve under plain Node (orcad) as well as the desktop. +// Why 'userData' + 'logs' rather than getPath('logs'): electron's 'logs' is ~/Library/Logs +// on macOS, which is NOT where this app has ever written. Changing it would strand +// existing log bundles. function getUserDataDir(): string { - try { - return app.getPath('userData') - } catch { - // Tests — Electron's `app` may not be initialized. Use an OS-conventional - // fallback so callers can resolve the path without the Electron runtime. - const home = homedir() - if (platform() === 'darwin') { - return join(home, 'Library', 'Application Support', 'Orca') - } - if (platform() === 'win32') { - return join(process.env.APPDATA ?? home, 'Orca') - } - return join(home, '.config', 'Orca') + if (hasAppEnvironment()) { + return getAppEnvironment().getPath('userData') } + const home = homedir() + if (platform() === 'darwin') { + return join(home, 'Library', 'Application Support', 'Orca') + } + if (platform() === 'win32') { + return join(process.env.APPDATA ?? home, 'Orca') + } + return join(home, '.config', 'Orca') } export function getLogsDirectory(): string { diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts new file mode 100644 index 00000000000..bd130bfb33f --- /dev/null +++ b/src/main/orcad/main-preflight-order.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' + +/** + * The precondition is only worth anything if it runs first. A loader failure is not + * catchable, so a preflight that lands after `main()` has already reached + * `await import('../ipc/pty')` prevents nothing. + */ +const order: string[] = [] + +vi.mock('./orcad-native-preflight', () => ({ + runOrcadNativePreflight: () => { + order.push('preflight') + return true + } +})) + +vi.mock('./orcad-entry', () => ({ + main: async () => { + order.push('main') + } +})) + +describe('orcad entry', () => { + it('runs the native preflight before starting the runtime', async () => { + await import('./main') + await vi.waitFor(() => expect(order).toContain('main')) + + expect(order).toEqual(['preflight', 'main']) + }) +}) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index e76ed873cb0..73a973c7b24 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -1,8 +1,27 @@ /** Executable entry for `orcad`. See `./orcad-entry.ts`. */ import process from 'node:process' -import { main } from './orcad-entry' +import { main, resolveOrcadExitCode } from './orcad-entry' +import { runOrcadNativePreflight } from './orcad-native-preflight' + +// Why exit before the preflight: reaching this line means the whole module graph resolved +// under plain Node, which is all the build guard needs to prove. Probing natives or +// starting a server to prove it would bind a port and take a data-root lock on a build +// machine. +if (process.argv.includes('--orcad-smoke-load-check')) { + process.exit(0) +} + +// Why here and not inside startOrcad: this must run before anything requires node-pty, +// and `orcad-entry` reaches it through `await import('../ipc/pty')`. Static imports are +// evaluated before this statement, so the guarantee is that no module in the graph +// requires node-pty at import time — which the bundle's lazy `require("node-pty")` in +// local-pty-provider satisfies. See ./node-pty-precondition.ts for why a child process. +runOrcadNativePreflight() main().catch((error: unknown) => { console.error('orcad: failed to start:', error) - process.exit(1) + // Why a resolved code and not a bare 1: a data-root or bind-address refusal is a + // configuration fault that restarting cannot fix, and a supervisor needs to tell the two + // apart to avoid restart-spinning on it. + process.exit(resolveOrcadExitCode(error)) }) diff --git a/src/main/orcad/native-host-abi.test.ts b/src/main/orcad/native-host-abi.test.ts new file mode 100644 index 00000000000..44f469f0faa --- /dev/null +++ b/src/main/orcad/native-host-abi.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + compareDottedVersions, + detectLibcFromReportHeader, + detectNativeHostAbi, + GLIBC_FLOOR, + isBelowGlibcFloor, + nativeSlotName, + parseNodeAbiMismatch, + parseUnmetGlibcVersion +} from './native-host-abi' + +describe('detectLibcFromReportHeader', () => { + it('reads glibc and its version from a glibc host report', () => { + expect(detectLibcFromReportHeader('linux', { glibcVersionRuntime: '2.31' })).toEqual({ + libc: 'glibc', + glibcVersion: '2.31' + }) + }) + + it('calls a Linux report with no glibcVersionRuntime musl', () => { + // Alpine's Node omits the key entirely; that absence is the only signal available + // without shelling out to ldd, which musl images do not usefully provide. + expect(detectLibcFromReportHeader('linux', { arch: 'x64' })).toEqual({ + libc: 'musl', + glibcVersion: null + }) + }) + + it('does not invent a libc dimension for macOS or Windows', () => { + // A darwin-arm64-glibc slot would never match anything CI builds. + expect(detectLibcFromReportHeader('darwin', { glibcVersionRuntime: '2.31' }).libc).toBe('none') + expect(detectLibcFromReportHeader('win32', undefined).libc).toBe('none') + }) + + it('treats an unreadable report as glibc with an unknown version, not as musl', () => { + // Guessing musl would send a glibc host looking for a slot that does not exist. + expect(detectLibcFromReportHeader('linux', undefined)).toEqual({ + libc: 'glibc', + glibcVersion: null + }) + }) +}) + +describe('nativeSlotName', () => { + it('carries libc on Linux and omits it elsewhere', () => { + expect(nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'glibc' })).toBe( + 'linux-x64-glibc' + ) + expect(nativeSlotName({ platform: 'linux', arch: 'arm64', libc: 'musl' })).toBe( + 'linux-arm64-musl' + ) + expect(nativeSlotName({ platform: 'darwin', arch: 'arm64', libc: 'none' })).toBe('darwin-arm64') + }) + + it('never lets a glibc slot answer for a musl host', () => { + // node-pty's own loader checks prebuilds/- with no libc, which is the + // exact confusion this name exists to prevent. + expect(nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'glibc' })).not.toBe( + nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'musl' }) + ) + }) +}) + +describe('glibc floor', () => { + it('compares dotted versions numerically, not lexically', () => { + // '2.9' > '2.31' under string compare; that ordering would pass a broken host. + expect(compareDottedVersions('2.9', '2.31')).toBe(-1) + expect(compareDottedVersions('2.31', '2.31.0')).toBe(0) + expect(compareDottedVersions('2.34', GLIBC_FLOOR)).toBe(1) + }) + + it('answers null when the version is unknown rather than claiming the floor is met', () => { + expect(isBelowGlibcFloor(null)).toBeNull() + expect(isBelowGlibcFloor('2.28')).toBe(true) + expect(isBelowGlibcFloor('2.31')).toBe(false) + }) +}) + +describe('loader error parsing', () => { + it('extracts the unmet symbol version from the #9902 message', () => { + expect( + parseUnmetGlibcVersion( + "/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /app/node_modules/node-pty/build/Release/pty.node)" + ) + ).toBe('2.34') + }) + + it('ignores unrelated loader noise', () => { + expect(parseUnmetGlibcVersion('Error: Cannot find module ./pty.node')).toBeNull() + }) + + it('extracts both ABI numbers from a NODE_MODULE_VERSION mismatch', () => { + expect( + parseNodeAbiMismatch( + 'was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.' + ) + ).toEqual({ built: '115', host: '127' }) + }) +}) + +describe('detectNativeHostAbi', () => { + it('describes the host this test is running on', () => { + const abi = detectNativeHostAbi() + expect(abi.platform).toBe(process.platform) + expect(abi.arch).toBe(process.arch) + expect(abi.nodeAbi).toBe(process.versions.modules) + expect(abi.libc).toBe(process.platform === 'linux' ? abi.libc : 'none') + }) +}) diff --git a/src/main/orcad/native-host-abi.ts b/src/main/orcad/native-host-abi.ts new file mode 100644 index 00000000000..4afb8ac7bfa --- /dev/null +++ b/src/main/orcad/native-host-abi.ts @@ -0,0 +1,127 @@ +/** + * What this host would load a native addon against: platform, arch, libc flavour and + * Node's ABI number, plus the prebuild slot name those four pick. + * + * Why libc is a first-class dimension: node-pty's own loader + * (`node_modules/node-pty/lib/utils.js`) falls back to `prebuilds/-`, + * which does NOT distinguish glibc from musl. A glibc binary dropped in that directory + * is loaded on Alpine and dies inside the dynamic loader. Slot names carry the libc so + * a mismatch is a miss rather than a crash. + * + * Everything here is pure apart from `detectNativeHostAbi`, so the classification can be + * tested for hosts this machine is not. + */ +import process from 'node:process' + +export type LibcFlavor = 'glibc' | 'musl' | 'none' + +export type NativeHostAbi = { + platform: NodeJS.Platform + arch: string + libc: LibcFlavor + /** Runtime glibc version ('2.31'), or null on musl, non-Linux, and unreadable reports. */ + glibcVersion: string | null + /** `NODE_MODULE_VERSION` — the addon ABI this runtime accepts. */ + nodeAbi: string +} + +/** Stock Ubuntu 20.04. See docs/reference/linux-glibc-compatibility.md. */ +export const GLIBC_FLOOR = '2.31' + +type ReportHeader = { glibcVersionRuntime?: unknown } + +/** + * Why absence means musl: `glibcVersionRuntime` is written by Node's report only when the + * process is linked against glibc. Alpine's Node omits it. This avoids shelling out to + * `ldd`, which is not present on every image and prints to stderr on musl. + * + * Non-Linux hosts get 'none': macOS and Windows have one system libc, so the dimension + * carries no information and must not widen the slot matrix. + */ +export function detectLibcFromReportHeader( + platform: NodeJS.Platform, + header: unknown +): { libc: LibcFlavor; glibcVersion: string | null } { + if (platform !== 'linux') { + return { libc: 'none', glibcVersion: null } + } + if (!header || typeof header !== 'object') { + // Why glibc and not 'unknown': an unreadable report is not evidence of musl, and + // glibc is the overwhelmingly common Linux case. The null version keeps the floor + // check from claiming a number it does not have. + return { libc: 'glibc', glibcVersion: null } + } + const runtime = (header as ReportHeader).glibcVersionRuntime + if (typeof runtime === 'string' && runtime.length > 0) { + return { libc: 'glibc', glibcVersion: runtime } + } + if ('glibcVersionRuntime' in (header as object)) { + return { libc: 'glibc', glibcVersion: null } + } + return { libc: 'musl', glibcVersion: null } +} + +export function detectNativeHostAbi(): NativeHostAbi { + let header: unknown + try { + header = (process.report?.getReport?.() as { header?: unknown } | undefined)?.header + } catch { + header = undefined + } + const { libc, glibcVersion } = detectLibcFromReportHeader(process.platform, header) + return { + platform: process.platform, + arch: process.arch, + libc, + glibcVersion, + nodeAbi: process.versions.modules + } +} + +/** `linux-x64-glibc`, `linux-arm64-musl`, `darwin-arm64`, `win32-x64`. */ +export function nativeSlotName(abi: Pick): string { + return abi.libc === 'none' + ? `${abi.platform}-${abi.arch}` + : `${abi.platform}-${abi.arch}-${abi.libc}` +} + +/** Numeric dotted compare: -1 / 0 / 1. Missing components read as 0, so '2.31' === '2.31.0'. */ +export function compareDottedVersions(left: string, right: string): number { + const a = left.split('.').map((part) => Number.parseInt(part, 10) || 0) + const b = right.split('.').map((part) => Number.parseInt(part, 10) || 0) + for (let i = 0; i < Math.max(a.length, b.length); i += 1) { + const diff = (a[i] ?? 0) - (b[i] ?? 0) + if (diff !== 0) { + return diff > 0 ? 1 : -1 + } + } + return 0 +} + +/** Null when the version is unknown — an unread report must not read as "below the floor". */ +export function isBelowGlibcFloor(glibcVersion: string | null): boolean | null { + if (!glibcVersion) { + return null + } + return compareDottedVersions(glibcVersion, GLIBC_FLOOR) < 0 +} + +/** + * The symbol version node's loader complained about, e.g. + * `libc.so.6: version 'GLIBC_2.34' not found (required by .../pty.node)` -> '2.34'. + * This is the fingerprint of #9902: a binary built on a newer glibc than the target. + */ +export function parseUnmetGlibcVersion(loaderError: string): string | null { + const match = loaderError.match(/version `?GLIBC_([0-9][0-9.]*)'? not found/) + return match ? match[1] : null +} + +/** `NODE_MODULE_VERSION 115 ... requires NODE_MODULE_VERSION 127` -> { built: '115', host: '127' }. */ +export function parseNodeAbiMismatch( + loaderError: string +): { built: string; host: string } | null { + const match = loaderError.match( + /NODE_MODULE_VERSION\s+(\d+)\D+NODE_MODULE_VERSION\s+(\d+)/ + ) + return match ? { built: match[1], host: match[2] } : null +} diff --git a/src/main/orcad/node-pty-prebuilt-slot.test.ts b/src/main/orcad/node-pty-prebuilt-slot.test.ts new file mode 100644 index 00000000000..00880eee1e5 --- /dev/null +++ b/src/main/orcad/node-pty-prebuilt-slot.test.ts @@ -0,0 +1,119 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + installPrebuiltSlot, + readPrebuiltSlotManifest, + resolveOrcadPrebuildsDir +} from './node-pty-prebuilt-slot' +import type { NativeHostAbi } from './native-host-abi' + +const LINUX_GLIBC: NativeHostAbi = { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '127' +} + +const dirs: string[] = [] +const temp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-')) + dirs.push(dir) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + delete process.env.ORCA_ORCAD_PREBUILDS_DIR +}) + +const stageSlot = (prebuilds: string, slot: string): void => { + mkdirSync(join(prebuilds, slot), { recursive: true }) + writeFileSync(join(prebuilds, slot, 'pty.node'), 'binary') + writeFileSync(join(prebuilds, slot, 'spawn-helper'), 'helper') +} + +describe('resolveOrcadPrebuildsDir', () => { + it('looks beside the running bundle', () => { + expect(resolveOrcadPrebuildsDir('/opt/orcad/orcad.js')).toBe(join('/opt/orcad', 'prebuilds')) + }) + + it('honours an explicit override', () => { + process.env.ORCA_ORCAD_PREBUILDS_DIR = '/custom/prebuilds' + expect(resolveOrcadPrebuildsDir('/opt/orcad/orcad.js')).toBe('/custom/prebuilds') + }) +}) + +describe('installPrebuiltSlot', () => { + it('installs the slot binary and spawn-helper into build/Release', () => { + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'linux-x64-glibc') + + const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds }) + + expect(outcome).toEqual({ installed: true, slot: 'linux-x64-glibc', spawnHelper: true }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(true) + // Without the executable bit every spawn fails EACCES at the moment a user opens a terminal. + const helper = statSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper')) + expect(helper.mode & 0o111).not.toBe(0) + }) + + it('will not load a glibc slot on a musl host', () => { + // node-pty's own loader cannot tell these apart; the slot name is the only thing that can. + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'linux-x64-glibc') + + const outcome = installPrebuiltSlot({ + abi: { ...LINUX_GLIBC, libc: 'musl' }, + nodePtyDir, + prebuildsDir: prebuilds + }) + + expect(outcome).toEqual({ installed: false, slot: 'linux-x64-musl', why: 'no-slot' }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) + }) + + it('refuses an ABI-mismatched matrix instead of installing a binary that cannot load', () => { + // Installing it would turn "no prebuilt for this host" into a loader failure that + // reads as a corrupt install. + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'linux-x64-glibc') + writeFileSync( + join(prebuilds, 'manifest.json'), + JSON.stringify({ module: 'node-pty', version: '1.1.0', nodeAbi: '115', slots: [] }) + ) + + const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds }) + + expect(outcome).toMatchObject({ installed: false, why: 'abi-mismatch' }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) + }) + + it('reports a missing prebuilds directory distinctly from a missing slot', () => { + // They mean different things: no matrix shipped at all, versus a matrix with a hole. + expect( + installPrebuiltSlot({ + abi: LINUX_GLIBC, + nodePtyDir: temp(), + prebuildsDir: join(temp(), 'absent') + }) + ).toEqual({ installed: false, slot: 'linux-x64-glibc', why: 'no-prebuilds-dir' }) + }) +}) + +describe('readPrebuiltSlotManifest', () => { + it('returns null for absent or malformed manifests rather than a half-built object', () => { + const prebuilds = temp() + expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() + writeFileSync(join(prebuilds, 'manifest.json'), '{ not json') + expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() + writeFileSync(join(prebuilds, 'manifest.json'), JSON.stringify({ version: '1.1.0' })) + expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() + }) +}) diff --git a/src/main/orcad/node-pty-prebuilt-slot.ts b/src/main/orcad/node-pty-prebuilt-slot.ts new file mode 100644 index 00000000000..992cbe705ee --- /dev/null +++ b/src/main/orcad/node-pty-prebuilt-slot.ts @@ -0,0 +1,113 @@ +/** + * Install the shipped node-pty prebuilt that matches this host, so a deployment needs + * no C/C++ toolchain. + * + * Why copy into `build/Release` rather than leave it in `prebuilds/`: node-pty's own + * loader falls back to `prebuilds/-` with no libc in the name, so a + * glibc binary parked there is loaded on Alpine and dies in the dynamic loader. Putting + * the chosen slot's binary in `build/Release` is what makes the libc dimension real — + * node-pty only ever sees the one we picked. + * + * The prebuilds themselves are built from the PATCHED source (config/patches/node-pty@1.1.0.patch) + * by config/scripts/build-orcad-prebuilds.mjs. An upstream tarball would not do: the patch + * carries the `.symver` pins and the `--no-as-needed` libutil/libpthread flags that hold the + * Ubuntu 20.04 / glibc 2.31 floor (docs/reference/linux-glibc-compatibility.md). + */ +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { nativeSlotName, type NativeHostAbi } from './native-host-abi' + +export type PrebuiltSlotManifest = { + module: string + version: string + nodeAbi: string + slots: string[] +} + +export type PrebuiltSlotOutcome = + | { installed: true; slot: string; spawnHelper: boolean } + | { installed: false; slot: string; why: 'no-slot' | 'no-prebuilds-dir' | 'abi-mismatch'; detail?: string } + +/** + * Where a deployment's prebuilds live: beside the bundle that is running. `argv[1]` is + * `orcad.js` itself, so this stays correct wherever the install directory ends up. + */ +export function resolveOrcadPrebuildsDir(entryScript = process.argv[1]): string | null { + const override = process.env.ORCA_ORCAD_PREBUILDS_DIR + if (override) { + return override + } + return entryScript ? join(dirname(entryScript), 'prebuilds') : null +} + +export function readPrebuiltSlotManifest(prebuildsDir: string): PrebuiltSlotManifest | null { + try { + const parsed = JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) as unknown + if (!parsed || typeof parsed !== 'object') { + return null + } + const manifest = parsed as Partial + if (typeof manifest.nodeAbi !== 'string' || typeof manifest.version !== 'string') { + return null + } + return { + module: typeof manifest.module === 'string' ? manifest.module : 'node-pty', + version: manifest.version, + nodeAbi: manifest.nodeAbi, + slots: Array.isArray(manifest.slots) ? manifest.slots.filter((s) => typeof s === 'string') : [] + } + } catch { + return null + } +} + +/** + * Copy `//pty.node` (and `spawn-helper`) into node-pty's `build/Release`. + * + * Refuses on an ABI mismatch instead of copying: a binary built for another + * `NODE_MODULE_VERSION` cannot load, and installing it would replace a "no prebuilt" + * diagnosis with a loader failure that reads as a corrupt install. + */ +export function installPrebuiltSlot(options: { + abi: NativeHostAbi + nodePtyDir: string + prebuildsDir?: string | null +}): PrebuiltSlotOutcome { + const slot = nativeSlotName(options.abi) + const prebuildsDir = options.prebuildsDir ?? resolveOrcadPrebuildsDir() + if (!prebuildsDir || !existsSync(prebuildsDir)) { + return { installed: false, slot, why: 'no-prebuilds-dir' } + } + const manifest = readPrebuiltSlotManifest(prebuildsDir) + if (manifest && manifest.nodeAbi !== options.abi.nodeAbi) { + return { + installed: false, + slot, + why: 'abi-mismatch', + detail: `shipped prebuilds target Node ABI ${manifest.nodeAbi}, this host runs ABI ${options.abi.nodeAbi}` + } + } + const source = join(prebuildsDir, slot, 'pty.node') + if (!existsSync(source)) { + return { installed: false, slot, why: 'no-slot' } + } + const releaseDir = join(options.nodePtyDir, 'build', 'Release') + mkdirSync(releaseDir, { recursive: true }) + copyFileSync(source, join(releaseDir, 'pty.node')) + + // Why this matters as much as pty.node: on Unix node-pty posix_spawns + // build/Release/spawn-helper. Without it every spawn fails with ENOENT at the moment + // a user opens a terminal, long after the "install succeeded" line. + let spawnHelper = false + if (options.abi.platform !== 'win32') { + const helperSource = join(prebuildsDir, slot, 'spawn-helper') + if (existsSync(helperSource)) { + const helperDest = join(releaseDir, 'spawn-helper') + copyFileSync(helperSource, helperDest) + chmodSync(helperDest, 0o755) + spawnHelper = true + } + } + return { installed: true, slot, spawnHelper } +} diff --git a/src/main/orcad/node-pty-precondition.test.ts b/src/main/orcad/node-pty-precondition.test.ts new file mode 100644 index 00000000000..fadb144d1ff --- /dev/null +++ b/src/main/orcad/node-pty-precondition.test.ts @@ -0,0 +1,358 @@ +import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildNodePtyLoadProbeScript, + checkNodePtyPrecondition, + classifyNodePtyProbeResult, + formatNodePtyPreconditionReport, + probeLocalBuildToolchainHints +} from './node-pty-precondition' +import { detectNativeHostAbi } from './native-host-abi' + +const require = createRequire(import.meta.url) +const REAL_NODE_PTY = dirname(require.resolve('node-pty/package.json')) +const REAL_PTY_NODE = join(REAL_NODE_PTY, 'build', 'Release', 'pty.node') + +/** + * Whether this host's compiled node-pty actually loads under plain Node. + * + * Why not existsSync: CI ships a pty.node built for Electron's ABI, so the file is + * present and `require` still fails. Gating on existence ran the load-dependent tests + * on a host that could never satisfy them. Probed in a child so a bad binding cannot + * take the test runner down with it. + */ +const REAL_SPAWN_HELPER = join(REAL_NODE_PTY, 'build', 'Release', 'spawn-helper') + +const realNodePtyLoads = ((): boolean => { + if (!existsSync(REAL_PTY_NODE)) { + return false + } + // Why spawn-helper too: a slot without it is legitimately 'degraded', so a test that + // expects 'ok' has an unsatisfiable premise on a host that lacks it. CI has the + // binding but not the helper, which is what made the previous gate insufficient. + if (process.platform !== 'win32' && !existsSync(REAL_SPAWN_HELPER)) { + return false + } + const probe = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(REAL_PTY_NODE)})`], { + encoding: 'utf8', + timeout: 30_000 + }) + return !probe.error && probe.status === 0 +})() + +const probe = (overrides: Partial[0]> = {}) => + classifyNodePtyProbeResult({ + code: 1, + signal: null, + stdout: '', + stderr: '', + timedOut: false, + ...overrides + }) + +const reported = (message: string) => + classifyNodePtyProbeResult({ + code: 4, + signal: null, + stdout: `ORCA_NODE_PTY_LOAD_ERROR ${JSON.stringify(message)}\n`, + stderr: '', + timedOut: false + }) + +describe('classifyNodePtyProbeResult', () => { + it('accepts only a clean exit that printed the token', () => { + expect(probe({ code: 0, stdout: 'ORCA_NODE_PTY_LOAD_OK /x/build/Release' })).toBeNull() + // A zero exit with no token means the probe never reached the load. + expect(probe({ code: 0, stdout: '' })?.reason).toBe('load_failed') + }) + + it('names the glibc floor for the #9902 loader message', () => { + expect( + reported( + "/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /app/node_modules/node-pty/build/Release/pty.node)" + ) + ).toEqual({ + status: 'blocked', + reason: 'libc_floor', + detail: 'the binary requires GLIBC_2.34' + }) + }) + + it('names a libstdc++ floor break too', () => { + expect(reported("version `GLIBCXX_3.4.29' not found")?.reason).toBe('libc_floor') + }) + + it('separates a Node ABI mismatch from a libc mismatch', () => { + // These need different fixes — rebuild against this Node vs. build on an older libc — + // so collapsing them sends the operator to the wrong one. + expect( + reported( + 'was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.' + ) + ).toEqual({ + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for Node ABI 115, this host runs ABI 127' + }) + }) + + it('reports a signalled probe as a crash, even with no output at all', () => { + // The uncatchable case: a binary that aborts inside the loader never reaches the + // child's catch and often prints nothing. + expect(probe({ code: null, signal: 'SIGSEGV' })).toEqual({ + status: 'blocked', + reason: 'load_crashed', + detail: 'the load probe was killed by SIGSEGV' + }) + }) + + it('distinguishes no binary anywhere from a binary the loader refused', () => { + // "install node-pty" and "rebuild node-pty for this libc" are different instructions. + expect(probe({ code: 3, stdout: 'ORCA_NODE_PTY_NO_BINARY\n' })).toEqual({ + status: 'blocked', + reason: 'dependency_missing', + detail: 'node-pty is installed but has no compiled binary for this platform' + }) + expect(reported('dlopen(...): slice is not valid mach-o file')?.reason).toBe('load_failed') + }) + + it('ignores its own token strings echoed back inside the child stderr', () => { + // node prints the whole `-e` source above the stack trace, and that source contains + // every token below. Matching on stderr made a refused binary read as "not installed". + const echoedSource = + '[eval]:1\nif(!f){console.log("ORCA_NODE_PTY_NO_BINARY");process.exit(3)}\n' + + ' ^\n\nError: dlopen(/app/pty.node): slice is not valid mach-o file\n' + + expect( + classifyNodePtyProbeResult({ + code: 4, + signal: null, + stdout: `ORCA_NODE_PTY_LOAD_ERROR ${JSON.stringify('dlopen(/app/pty.node): slice is not valid mach-o file')}`, + stderr: echoedSource, + timedOut: false + }) + ).toMatchObject({ reason: 'load_failed' }) + }) + + it('reads past node\u2019s echoed source line when it can only use stderr', () => { + const failure = probe({ + stderr: + '[eval]:1\nprocess.dlopen({exports:{}},f);\n ^\n\nError: something specific went wrong\n' + }) + expect(failure?.detail).toBe('Error: something specific went wrong') + }) + + it('calls a timeout unverifiable rather than blocked', () => { + // A probe that never answered is not evidence that node-pty is broken, and refusing + // to boot on it would take down hosts that work. + expect(probe({ timedOut: true })).toEqual({ + status: 'unverifiable', + reason: 'unknown', + detail: 'the node-pty load probe did not finish in time, so nothing was established' + }) + }) +}) + +describe('buildNodePtyLoadProbeScript', () => { + it('loads through absolute paths so the child cannot resolve a different copy', () => { + const script = buildNodePtyLoadProbeScript('/opt/app/node_modules/node-pty') + expect(script).toContain('"/opt/app/node_modules/node-pty/lib/index.js"') + expect(script).toContain('"/opt/app/node_modules/node-pty/lib/utils.js"') + expect(script).toContain('loadNativeModule') + // Windows defers conpty.node to first spawn, so requiring the package proves nothing there. + expect(script).toContain('conpty') + // The raw dlopen must precede requiring the package, or node-pty's own loader + // re-wraps the loader error into a misleading "Cannot find module". + expect(script.indexOf('process.dlopen')).toBeLessThan(script.indexOf('lib/index.js')) + }) +}) + +describe('checkNodePtyPrecondition', () => { + const temporaryDirs: string[] = [] + const stageNodePty = (): string => { + const root = mkdtempSync(join(tmpdir(), 'orcad-node-pty-')) + temporaryDirs.push(root) + const dir = join(root, 'node-pty') + mkdirSync(join(dir, 'build', 'Release'), { recursive: true }) + cpSync(join(REAL_NODE_PTY, 'lib'), join(dir, 'lib'), { recursive: true }) + cpSync(join(REAL_NODE_PTY, 'package.json'), join(dir, 'package.json')) + return dir + } + + afterEach(() => { + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('survives a native binary that the dynamic loader refuses', () => { + // The whole reason the probe is a child process: this file is loaded with dlopen, and + // an in-process require of it can take the host down before any handler runs. Reaching + // the assertion below at all is the evidence. + const dir = stageNodePty() + writeFileSync(join(dir, 'build', 'Release', 'pty.node'), Buffer.from('not a native addon')) + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir: null }) + + expect(verdict.status).toBe('blocked') + expect(verdict.reason).toBeDefined() + expect(verdict.reason).not.toBe('spawn_helper_missing') + }) + + it('blocks when node-pty is not resolvable at all', () => { + const verdict = checkNodePtyPrecondition({ nodePtyDir: null, prebuildsDir: null }) + expect(verdict).toMatchObject({ status: 'blocked', reason: 'dependency_missing' }) + }) + + it('returns a self-consistent verdict against the real host', () => { + // Why not a predicted status: this depends on how the host was prepared. CI's test + // shard runs `vitest` directly, so `ensure-native-runtime --runtime=node` never + // builds node-pty for the Node ABI and `degraded` is correct there; a prepared + // checkout gives 'ok'. Predicting either encodes an environment. + // + // Why not require('node-pty') as ground truth: that resolves the JS wrapper while + // the native binding loads lazily, so it proves strictly less than this checks — + // that was the first version of this test and it failed on CI for that reason. + // + // What is invariant: on a host where node-pty is installed at all, the verdict is + // never 'blocked' and never carries an unestablished reason. + const verdict = checkNodePtyPrecondition({ prebuildsDir: null }) + + // Why not a fixed status: a prepared host gives 'ok', CI's unprepared shard gives + // 'degraded', and a corrupt binding gives 'blocked' — all three are honest. What is + // invariant is that anything other than 'ok' names an established cause, so the + // host can never decline a terminal for a reason it did not work out. + expect(['ok', 'degraded', 'blocked', 'unverifiable']).toContain(verdict.status) + if (verdict.status !== 'ok') { + expect(verdict.reason).toBeDefined() + expect(verdict.reason).not.toBe('unknown') + } + expect(verdict.slot).toBe( + process.platform === 'linux' + ? `linux-${process.arch}-${verdict.abi.libc}` + : `${process.platform}-${process.arch}` + ) + }) + + // Why gated on the real binding: this asserts a LOAD outcome, so it needs a pty.node + // built for the Node ABI. CI's shard never runs ensure-native-runtime, so the copy + // ENOENT'd there. + it.runIf(process.platform !== 'win32' && realNodePtyLoads)( + 'degrades rather than blocks when only spawn-helper is missing', + () => { + // node-pty posix_spawns spawn-helper, so this host loads fine and then fails ENOENT + // the first time someone opens a terminal. Everything else it serves still works. + const dir = stageNodePty() + cpSync( + join(REAL_NODE_PTY, 'build', 'Release', 'pty.node'), + join(dir, 'build', 'Release', 'pty.node') + ) + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir: null }) + + expect(verdict).toMatchObject({ status: 'degraded', reason: 'spawn_helper_missing' }) + } + ) + + // Why split: the "ok" half needs a REAL loadable pty.node, which only exists after + // `ensure-native-runtime --runtime=node`. CI's shard runs vitest directly, so copying + // from node_modules ENOENT'd there. Slot *placement* is the logic worth checking on + // every host; the load verdict needs a prepared one. + it('places the matching slot even when the payload is not loadable', () => { + const dir = stageNodePty() + const abi = detectNativeHostAbi() + const slot = + abi.libc === 'none' + ? `${abi.platform}-${abi.arch}` + : `${abi.platform}-${abi.arch}-${abi.libc}` + const prebuildsDir = mkdtempSync(join(tmpdir(), 'orcad-prebuilds-')) + temporaryDirs.push(prebuildsDir) + mkdirSync(join(prebuildsDir, slot), { recursive: true }) + writeFileSync(join(prebuildsDir, slot, 'pty.node'), 'not a real binding') + if (process.platform !== 'win32') { + writeFileSync(join(prebuildsDir, slot, 'spawn-helper'), '#!/bin/sh\nexit 0\n') + } + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir }) + + // Installed from the right slot, and honest that the payload does not load. + expect(verdict.prebuilt).toMatchObject({ installed: true, slot }) + expect(verdict.status).not.toBe('ok') + expect(verdict.reason).toBeDefined() + }) + + it.runIf(realNodePtyLoads)( + 'reports ok once a loadable slot is installed (needs a Node-ABI build)', + () => { + const dir = stageNodePty() + const abi = detectNativeHostAbi() + const slot = + abi.libc === 'none' + ? `${abi.platform}-${abi.arch}` + : `${abi.platform}-${abi.arch}-${abi.libc}` + const prebuildsDir = mkdtempSync(join(tmpdir(), 'orcad-prebuilds-')) + temporaryDirs.push(prebuildsDir) + mkdirSync(join(prebuildsDir, slot), { recursive: true }) + cpSync(REAL_PTY_NODE, join(prebuildsDir, slot, 'pty.node')) + const helper = REAL_SPAWN_HELPER + if (process.platform !== 'win32' && existsSync(helper)) { + cpSync(helper, join(prebuildsDir, slot, 'spawn-helper')) + } + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir }) + + expect(verdict.prebuilt).toMatchObject({ installed: true, slot }) + expect(verdict.status).toBe('ok') + } + ) +}) + +describe('formatNodePtyPreconditionReport', () => { + it('names the host, the slot and the action', () => { + const report = formatNodePtyPreconditionReport( + { + status: 'blocked', + slot: 'linux-x64-musl', + abi: { + platform: 'linux', + arch: 'x64', + libc: 'musl', + glibcVersion: null, + nodeAbi: '127' + }, + reason: 'dependency_missing', + prebuilt: { installed: false, slot: 'linux-x64-musl', why: 'no-slot' } + }, + 'Terminals are unavailable on this host.', + [' sudo apk add build-base python3'] + ) + + expect(report).toContain('platform linux/x64') + expect(report).toContain('libc musl') + expect(report).toContain('prebuild slot linux-x64-musl') + expect(report).toContain('No shipped prebuilt matches slot linux-x64-musl.') + expect(report).toContain('sudo apk add build-base python3') + }) +}) + +describe('probeLocalBuildToolchainHints', () => { + it('gives macOS the Xcode command line tools, not a Linux package manager', () => { + // The relay's hint list answers with a cross-distro apt/dnf/pacman/apk menu when it + // finds no package manager. On macOS every line of that menu is wrong. + const hints = probeLocalBuildToolchainHints('darwin') + expect(hints).toEqual([' xcode-select --install']) + expect(hints.join('\n')).not.toMatch(/apt-get|dnf|pacman|apk/) + }) + + it('says nothing on Windows, where node-pty ships prebuilds', () => { + expect(probeLocalBuildToolchainHints('win32')).toEqual([]) + }) + + it.runIf(process.platform === 'linux')('reuses the relay diagnosis on Linux', () => { + expect(probeLocalBuildToolchainHints('linux').length).toBeGreaterThan(0) + }) +}) diff --git a/src/main/orcad/node-pty-precondition.ts b/src/main/orcad/node-pty-precondition.ts new file mode 100644 index 00000000000..2857449c736 --- /dev/null +++ b/src/main/orcad/node-pty-precondition.ts @@ -0,0 +1,365 @@ +/** + * Prove `node-pty` can be loaded on this host BEFORE anything in the process requires it. + * + * Why this exists: of the two ways node-pty fails, only one is catchable. A missing + * module throws `MODULE_NOT_FOUND` and a caller can degrade. A module that is present but + * built against the wrong libc or Node ABI is refused by the dynamic loader, and in the + * worst case takes the process down before any handler exists — that is #9902, which + * crashed the desktop app on Ubuntu 20.04 before a window appeared + * (docs/reference/linux-glibc-compatibility.md). + * + * So the load happens in a CHILD process. Whatever the child does — throw, abort, die on + * a signal — is data to us rather than our own death, and the operator gets a sentence + * naming what to change instead of a loader stack trace. + * + * The cost is one short-lived `node -e` at startup. That is the price of turning an + * uncatchable failure into a catchable one, and it is paid once per boot. + */ +import { existsSync, accessSync, constants } from 'node:fs' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types' +import { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + toolchainInstallHintLines +} from '../ssh/build-toolchain-diagnosis' +import { + detectNativeHostAbi, + nativeSlotName, + parseNodeAbiMismatch, + parseUnmetGlibcVersion, + type NativeHostAbi +} from './native-host-abi' +import { installPrebuiltSlot, type PrebuiltSlotOutcome } from './node-pty-prebuilt-slot' + +// Why every verdict travels on STDOUT: node echoes the whole `-e` source into stderr +// before the stack trace, so any substring test against stderr also matches this file's +// own token strings. stdout carries only what the child chose to print. +const PROBE_OK_TOKEN = 'ORCA_NODE_PTY_LOAD_OK' +const NO_BINARY_TOKEN = 'ORCA_NODE_PTY_NO_BINARY' +const LOAD_ERROR_TOKEN = 'ORCA_NODE_PTY_LOAD_ERROR' +const PROBE_TIMEOUT_MS = 20_000 + +/** + * `ok` — proved loadable. `degraded` — loads, but something only spawn-time needs is + * broken, so the host should still serve everything else. `blocked` — proved unloadable, + * so nothing in this process may require it. `unverifiable` — the probe itself did not + * answer, which is not evidence either way. + * + * Why `unverifiable` is separate from `blocked`: a probe that times out or cannot spawn + * says nothing about node-pty, and refusing to boot on it would brick working hosts for + * a reason that was never established. Same verdict discipline as + * docs/reference/ssh-execution-boundary.md — loss of contact is not proof of death. + */ +export type NodePtyPreconditionStatus = 'ok' | 'degraded' | 'blocked' | 'unverifiable' + +export type NodePtyPreconditionVerdict = { + status: NodePtyPreconditionStatus + slot: string + abi: NativeHostAbi + reason?: RuntimeTerminalUnavailableReason + detail?: string + /** What the slot install did, when one was attempted. */ + prebuilt?: PrebuiltSlotOutcome +} + +export type NodePtyProbeFailure = { + status: 'blocked' | 'unverifiable' + reason: RuntimeTerminalUnavailableReason + detail: string +} + +/** + * Read the child's exit into a cause. Pure, so every failure shape is testable from a + * host that cannot reproduce it — the whole point, since the shapes that matter belong + * to Alpine and Ubuntu 20.04. + */ +export function classifyNodePtyProbeResult( + result: Pick +): NodePtyProbeFailure | null { + const stdout = result.stdout + if (result.code === 0 && stdout.includes(PROBE_OK_TOKEN)) { + return null + } + if (result.timedOut) { + return { + status: 'unverifiable', + reason: 'unknown', + detail: 'the node-pty load probe did not finish in time, so nothing was established' + } + } + // Why signal before anything the child said: a binary that aborts or segfaults inside + // the loader never reaches the catch, and often prints nothing at all. That silence is + // exactly the uncatchable case this probe is a separate process for. + if (result.signal) { + return { + status: 'blocked', + reason: 'load_crashed', + detail: `the load probe was killed by ${result.signal}` + } + } + if (stdout.includes(NO_BINARY_TOKEN)) { + return { + status: 'blocked', + reason: 'dependency_missing', + detail: 'node-pty is installed but has no compiled binary for this platform' + } + } + const reported = readReportedLoadError(stdout) + if (reported !== null) { + return classifyLoaderMessage(reported) + } + return { + status: 'blocked', + reason: 'load_failed', + detail: firstLine(result.stderr) || `the load probe exited with code ${result.code}` + } +} + +/** The message the child caught, or null when it never got that far. */ +function readReportedLoadError(stdout: string): string | null { + const line = stdout.split('\n').find((candidate) => candidate.startsWith(LOAD_ERROR_TOKEN)) + if (!line) { + return null + } + try { + return JSON.parse(line.slice(LOAD_ERROR_TOKEN.length).trim()) as string + } catch { + return line.slice(LOAD_ERROR_TOKEN.length).trim() + } +} + +/** Read a dynamic-loader message. Pure, so shapes this host cannot reproduce are testable. */ +export function classifyLoaderMessage(message: string): NodePtyProbeFailure { + const abiMismatch = parseNodeAbiMismatch(message) + if (abiMismatch) { + return { + status: 'blocked', + reason: 'abi_mismatch', + detail: `built for Node ABI ${abiMismatch.built}, this host runs ABI ${abiMismatch.host}` + } + } + const unmetGlibc = parseUnmetGlibcVersion(message) + if (unmetGlibc) { + return { + status: 'blocked', + reason: 'libc_floor', + detail: `the binary requires GLIBC_${unmetGlibc}` + } + } + if (/(GLIBCXX_|CXXABI_)[0-9.]+'? not found/.test(message)) { + return { status: 'blocked', reason: 'libc_floor', detail: firstLine(message) } + } + if (/MODULE_NOT_FOUND|Cannot find module/.test(message)) { + return { status: 'blocked', reason: 'dependency_missing', detail: firstLine(message) } + } + return { status: 'blocked', reason: 'load_failed', detail: firstLine(message) } +} + +/** + * Why not simply the first non-empty line: when the child dies without catching, node + * prints the offending source line and a caret before the error, so line one is the + * script rather than the diagnosis. Prefer the first line that reads as an error. + */ +function firstLine(text: string): string { + const lines = text.split('\n').filter((candidate) => candidate.trim().length > 0) + const errorLine = lines.find((candidate) => /^[A-Za-z]*(Error|Exception):/.test(candidate.trim())) + return (errorLine ?? lines[0] ?? text).trim().slice(0, 400) +} + +/** + * The script the child runs. + * + * Why it dlopens the file itself rather than trusting node-pty's loader: that loader + * tries several directories and rethrows only the LAST error, so a `pty.node` the + * dynamic loader refused is reported as `Cannot find module './prebuilds/...'`. Acting on + * that sends the operator to install a module that is already there. The dlopen has to + * come BEFORE `require(index.js)` for the same reason: node-pty's unixTerminal calls the + * loader at module scope, so requiring the package first re-wraps the error we came for. + * + * Why it catches and prints instead of throwing: a thrown error reaches us as a stack + * trace with the script source echoed above it, and the message we need is then one line + * inside a blob that also contains these very tokens. What the child cannot catch — a + * loader that aborts the process — still reaches us as a signal, which is the case this + * whole indirection exists for. + */ +export function buildNodePtyLoadProbeScript(nodePtyDir: string): string { + const entry = JSON.stringify(join(nodePtyDir, 'lib', 'index.js')) + const utils = JSON.stringify(join(nodePtyDir, 'lib', 'utils.js')) + const root = JSON.stringify(nodePtyDir) + // Same directory order node-pty's own loader walks, so the file opened here is the file + // it would load. Windows defers conpty.node to the first spawn, which is why the name is + // chosen the way node-pty chooses it rather than always being 'pty'. + return [ + `const fs=require('fs'),p=require('path');`, + `const n=process.platform==='win32'&&Number(require('os').release().split('.')[2])>=18309?'conpty':'pty';`, + `let f=null;`, + `for(const d of ['build/Release','build/Debug','prebuilds/'+process.platform+'-'+process.arch]){`, + `for(const r of [${root},p.join(${root},'lib')]){`, + `const c=p.join(r,d,n+'.node');if(fs.existsSync(c)){f=c;break}}if(f)break}`, + `if(!f){console.log(${JSON.stringify(NO_BINARY_TOKEN)});process.exit(3)}`, + `try{`, + `process.dlopen({exports:{}},f);`, + `require(${entry});`, + `require(${utils}).loadNativeModule(n);`, + `console.log(${JSON.stringify(PROBE_OK_TOKEN)}+' '+p.dirname(f));`, + `}catch(e){`, + `console.log(${JSON.stringify(LOAD_ERROR_TOKEN)}+' '+JSON.stringify(String((e&&e.message)||e)));`, + `process.exit(4)}` + ].join('') +} + +function resolveNodePtyDir(): string | null { + try { + // Why require.resolve and not import: resolution only — the load itself happens in + // the child process, which is the whole point of the precondition. + return dirname(require.resolve('node-pty/package.json')) + } catch { + return null + } +} + +/** Local equivalent of the relay's remote toolchain probe, reusing its pure half. */ +export function probeLocalBuildToolchainHints(platform: NodeJS.Platform): string[] { + if (platform === 'win32') { + return [] + } + // Why macOS is not routed through the relay's hints: that function answers with a + // cross-distro apt/dnf/pacman/apk menu when it finds no package manager, and none of + // those lines is the macOS answer. Printing them here would be confidently wrong. + if (platform === 'darwin') { + return [' xcode-select --install'] + } + try { + const result = runProcessSync({ + program: '/bin/sh', + args: ['-c', buildToolchainProbeCommand()], + timeoutMs: 10_000 + }) + return toolchainInstallHintLines(parseBuildToolchainProbe(result.stdout)) + } catch { + return [] + } +} + +export function checkNodePtyPrecondition( + options: { nodePtyDir?: string | null; abi?: NativeHostAbi; prebuildsDir?: string | null } = {} +): NodePtyPreconditionVerdict { + const abi = options.abi ?? detectNativeHostAbi() + const slot = nativeSlotName(abi) + // Why `in` and not `??`: an explicit `null` means "this host cannot resolve node-pty", + // which is a case tests must be able to state. `??` would silently re-detect instead. + const nodePtyDir = 'nodePtyDir' in options ? options.nodePtyDir : resolveNodePtyDir() + if (!nodePtyDir) { + return { + status: 'blocked', + slot, + abi, + reason: 'dependency_missing', + detail: 'node-pty is not resolvable from this install' + } + } + + // Why install before probing: on a toolchain-free deployment the compiled binary does + // not exist yet, and the shipped slot is the only thing that can make the probe pass. + let prebuilt: PrebuiltSlotOutcome | undefined + if (!existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))) { + prebuilt = installPrebuiltSlot({ + abi, + nodePtyDir, + ...(options.prebuildsDir === undefined ? {} : { prebuildsDir: options.prebuildsDir }) + }) + } + + let result: ProcessResult + try { + result = runProcessSync({ + program: process.execPath, + args: ['-e', buildNodePtyLoadProbeScript(nodePtyDir)], + timeoutMs: PROBE_TIMEOUT_MS + }) + } catch (error) { + return { + status: 'unverifiable', + slot, + abi, + reason: 'unknown', + detail: `the node-pty load probe could not be started: ${(error as Error).message}`, + ...(prebuilt ? { prebuilt } : {}) + } + } + const failure = classifyNodePtyProbeResult(result) + if (failure) { + return { + status: failure.status, + slot, + abi, + reason: failure.reason, + detail: failure.detail, + ...(prebuilt ? { prebuilt } : {}) + } + } + + // Loaded. The remaining way terminals fail is spawn-time: node-pty posix_spawns + // build/Release/spawn-helper, and a missing one turns every terminal.create into ENOENT + // on a host that otherwise looks healthy. That is a degradation, not a boot blocker. + const loadedDir = result.stdout.split(PROBE_OK_TOKEN)[1]?.trim().split('\n')[0]?.trim() + if (abi.platform !== 'win32') { + const helper = join(loadedDir || join(nodePtyDir, 'build', 'Release'), 'spawn-helper') + if (!isExecutableFile(helper)) { + return { + status: 'degraded', + slot, + abi, + reason: 'spawn_helper_missing', + detail: `expected an executable at ${helper}`, + ...(prebuilt ? { prebuilt } : {}) + } + } + } + return { status: 'ok', slot, abi, ...(prebuilt ? { prebuilt } : {}) } +} + +function isExecutableFile(path: string): boolean { + try { + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** The operator-facing report. Names the host, the cause, and the next action. */ +export function formatNodePtyPreconditionReport( + verdict: NodePtyPreconditionVerdict, + message: string, + toolchainHints: string[] = [] +): string { + const { abi } = verdict + const host = [ + `platform ${abi.platform}/${abi.arch}`, + abi.libc === 'none' + ? null + : `libc ${abi.libc}${abi.glibcVersion ? ` ${abi.glibcVersion}` : ''}`, + `Node ABI ${abi.nodeAbi}`, + `prebuild slot ${verdict.slot}` + ] + .filter((part): part is string => part !== null) + .join(', ') + const lines = [message, '', `Host: ${host}`] + if (verdict.prebuilt && !verdict.prebuilt.installed) { + lines.push( + verdict.prebuilt.why === 'no-slot' + ? `No shipped prebuilt matches slot ${verdict.slot}.` + : verdict.prebuilt.why === 'no-prebuilds-dir' + ? 'This install ships no prebuilds directory.' + : `Shipped prebuilds are unusable here: ${verdict.prebuilt.detail ?? 'ABI mismatch'}.` + ) + } + if (toolchainHints.length > 0) { + lines.push('', 'To build node-pty on this host, install a C/C++ toolchain:', ...toolchainHints) + } + return lines.join('\n') +} diff --git a/src/main/orcad/orcad-bind-address.test.ts b/src/main/orcad/orcad-bind-address.test.ts new file mode 100644 index 00000000000..2ddbcc56343 --- /dev/null +++ b/src/main/orcad/orcad-bind-address.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + bindHostIsNetworkExposed, + describeOrcadBindExposure, + ORCAD_LOOPBACK_BIND_HOST, + OrcadBindAddressError, + resolveOrcadBindHost +} from './orcad-bind-address' + +describe('resolveOrcadBindHost', () => { + it('defaults to loopback when the operator asked for nothing', () => { + expect(resolveOrcadBindHost()).toBe(ORCAD_LOOPBACK_BIND_HOST) + expect(ORCAD_LOOPBACK_BIND_HOST).toBe('127.0.0.1') + }) + + it('accepts literal IPv4 and IPv6 addresses, including explicit wide binds', () => { + expect(resolveOrcadBindHost('0.0.0.0')).toBe('0.0.0.0') + expect(resolveOrcadBindHost('10.1.2.3')).toBe('10.1.2.3') + expect(resolveOrcadBindHost('::1')).toBe('::1') + expect(resolveOrcadBindHost('localhost')).toBe('127.0.0.1') + expect(resolveOrcadBindHost(' 127.0.0.1 ')).toBe('127.0.0.1') + }) + + it('refuses hostnames, because DNS would decide which interface got bound', () => { + expect(() => resolveOrcadBindHost('internal.example')).toThrow(OrcadBindAddressError) + expect(() => resolveOrcadBindHost('')).toThrow(OrcadBindAddressError) + expect(() => resolveOrcadBindHost('0.0.0.0:80')).toThrow(OrcadBindAddressError) + }) +}) + +describe('bindHostIsNetworkExposed', () => { + it('separates local-only addresses from network-reachable ones', () => { + expect(bindHostIsNetworkExposed('127.0.0.1')).toBe(false) + expect(bindHostIsNetworkExposed('127.5.5.5')).toBe(false) + expect(bindHostIsNetworkExposed('::1')).toBe(false) + expect(bindHostIsNetworkExposed('0.0.0.0')).toBe(true) + expect(bindHostIsNetworkExposed('::')).toBe(true) + expect(bindHostIsNetworkExposed('10.1.2.3')).toBe(true) + }) + + it('says out loud when a deployment is reachable from the network', () => { + expect(describeOrcadBindExposure('0.0.0.0')).toContain('reachable from the network') + expect(describeOrcadBindExposure('127.0.0.1')).toContain('local only') + }) +}) diff --git a/src/main/orcad/orcad-bind-address.ts b/src/main/orcad/orcad-bind-address.ts new file mode 100644 index 00000000000..d5fc4548dd1 --- /dev/null +++ b/src/main/orcad/orcad-bind-address.ts @@ -0,0 +1,66 @@ +/** + * The address `orcad` binds its RPC listener to. + * + * Why loopback by default: the desktop stays on loopback until the user pairs, and the + * shipping design reaches a remote orcad over an SSH local port-forward — so the wide + * bind orcad had was both a departure from the desktop's posture and unnecessary for the + * deploy model. Exposure is now something an operator asks for by name. + */ +import { isIP } from 'node:net' + +export const ORCAD_LOOPBACK_BIND_HOST = '127.0.0.1' +const ALL_INTERFACES_V4 = '0.0.0.0' +const ALL_INTERFACES_V6 = '::' + +export class OrcadBindAddressError extends Error { + readonly code = 'orcad_invalid_bind_address' +} + +/** + * Resolve `--bind`. Literal IPs only. + * + * Why not hostnames: `listen()` resolves a name through DNS, so the interface actually + * bound is decided by resolver configuration this process cannot see. An operator who + * writes `--bind internal.example` would have no way to know whether the service came up + * on a private interface or a public one. + */ +export function resolveOrcadBindHost(raw?: string): string { + if (raw === undefined) { + return ORCAD_LOOPBACK_BIND_HOST + } + const value = raw.trim() + if (value === '') { + throw new OrcadBindAddressError('--bind expects an address') + } + if (value === 'localhost') { + return ORCAD_LOOPBACK_BIND_HOST + } + if (isIP(value) === 0) { + throw new OrcadBindAddressError( + `--bind expects a literal IP address (got '${value}'). Hostnames are refused because ` + + 'DNS decides which interface would be bound. Use 127.0.0.1 for loopback, or ' + + '0.0.0.0 to expose every interface.' + ) + } + return value +} + +/** True when this address reaches beyond the local machine. */ +export function bindHostIsNetworkExposed(host: string): boolean { + if (host === ALL_INTERFACES_V4 || host === ALL_INTERFACES_V6) { + return true + } + if (isIP(host) === 4) { + return !host.startsWith('127.') + } + return host !== '::1' +} + +/** One line for the startup log, so an exposed deployment is never a silent default. */ +export function describeOrcadBindExposure(host: string): string { + return bindHostIsNetworkExposed(host) + ? `orcad is bound to ${host} and is reachable from the network. Anything that can reach ` + + 'this port can attempt pairing.' + : `orcad is bound to ${host} (local only). Reach it from another machine with an SSH ` + + 'local port-forward, or re-launch with --bind to expose it.' +} diff --git a/src/main/orcad/orcad-bundle-native-load-order.test.ts b/src/main/orcad/orcad-bundle-native-load-order.test.ts new file mode 100644 index 00000000000..1c26501b6a6 --- /dev/null +++ b/src/main/orcad/orcad-bundle-native-load-order.test.ts @@ -0,0 +1,91 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' + +/** + * The preflight only prevents a loader crash if nothing in the bundle's import graph has + * already required node-pty by the time it runs. esbuild wraps `local-pty-provider` in a + * lazy initializer because `orcad-entry` reaches it through `await import('../ipc/pty')`, + * and that laziness is load-bearing rather than incidental — a single top-level static + * import anywhere in the graph would hoist `require("node-pty")` above every statement in + * `main.ts`, including the preflight. + * + * Why this builds the bundle instead of skipping without one: no CI job builds orcad and + * runs vitest. `smoke:orcad-terminal` builds it in the static-analysis job, which never + * runs vitest; the `orcad_browser` job runs vitest but deliberately does not build orcad. + * A `runIf(existsSync(...))` guard therefore skips in every shard, forever — the same way + * an unset ORCA_BROWSER_EXECUTABLE kept the browser provider uncovered. + * + * Why not fail-when-CI instead: the wiring that would satisfy it lives in `.github/`, so + * that turns a silent gap into a red build someone else has to fix. Building costs well + * under a second (esbuild), works in every shard and on every machine, and needs no job + * to cooperate. What it must never do is skip. + */ +const REPO_ROOT = join(__dirname, '..', '..', '..') +const BUNDLE = join(REPO_ROOT, 'out', 'orcad', 'orcad.js') +const BUILD_SCRIPT = join(REPO_ROOT, 'config', 'scripts', 'build-orcad.mjs') + +/** + * Why it throws rather than skipping when the build fails: a bundle that cannot be built + * is a louder problem than the one this test checks, and swallowing it here is exactly + * how the assertion would go missing. + */ +function ensureOrcadBundle(): void { + if (existsSync(BUNDLE)) { + return + } + const build = runProcessSync({ + program: process.execPath, + args: [BUILD_SCRIPT], + cwd: REPO_ROOT, + timeoutMs: 300_000 + }) + if (!existsSync(BUNDLE)) { + const output = `${build.stdout}${build.stderr}`.slice(0, 4000) + throw new Error( + `could not build ${BUNDLE} (exit ${build.code}); the load-order assertion cannot run:\n${output}` + ) + } +} + +describe('orcad bundle native load order', () => { + const dirs: string[] = [] + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('does not require node-pty in-process before the entry rejects its argv', () => { + ensureOrcadBundle() + const dir = mkdtempSync(join(tmpdir(), 'orcad-load-order-')) + dirs.push(dir) + const harness = join(dir, 'harness.cjs') + writeFileSync( + harness, + [ + "const Module = require('module')", + 'const original = Module._load', + 'Module._load = function (request, ...rest) {', + " if (request === 'node-pty') { console.log('IN_PROCESS_NODE_PTY_REQUIRE') }", + ' return original.call(this, request, ...rest)', + '}', + "process.argv.push('--orcad-load-order-check')", + `require(${JSON.stringify(BUNDLE)})` + ].join('\n') + ) + + const result = runProcessSync({ + program: process.execPath, + args: [harness], + timeoutMs: 120_000 + }) + const output = `${result.stdout}${result.stderr}` + + // Proof the graph fully loaded and reached argv parsing rather than dying early. + expect(output).toContain('Unknown argument: --orcad-load-order-check') + expect(output).not.toContain('IN_PROCESS_NODE_PTY_REQUIRE') + }, 360_000) +}) diff --git a/src/main/orcad/orcad-daemon-supervision.test.ts b/src/main/orcad/orcad-daemon-supervision.test.ts new file mode 100644 index 00000000000..19616c9d5ef --- /dev/null +++ b/src/main/orcad/orcad-daemon-supervision.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + initDaemonPtyProviderMock, + disconnectDaemonMock, + shutdownDaemonMock, + daemonOwnsFreshPersistentPtysMock, + readDaemonPidRecordMock +} = vi.hoisted(() => ({ + initDaemonPtyProviderMock: vi.fn<(signal?: unknown, options?: unknown) => Promise>(), + disconnectDaemonMock: vi.fn<() => Promise>(), + shutdownDaemonMock: vi.fn<() => Promise>(), + daemonOwnsFreshPersistentPtysMock: vi.fn<() => boolean>(), + readDaemonPidRecordMock: vi.fn<() => { pid: number } | null>() +})) + +vi.mock('../daemon/daemon-init', () => ({ + initDaemonPtyProvider: initDaemonPtyProviderMock, + disconnectDaemon: disconnectDaemonMock, + // Exported here purely so the test can prove it is never reached — killing the daemon on + // orcad shutdown is what would make an orcad restart destructive again. + shutdownDaemon: shutdownDaemonMock, + daemonOwnsFreshPersistentPtys: daemonOwnsFreshPersistentPtysMock, + readDaemonPidRecord: readDaemonPidRecordMock +})) + +const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') + +beforeEach(() => { + initDaemonPtyProviderMock.mockResolvedValue() + disconnectDaemonMock.mockResolvedValue() + shutdownDaemonMock.mockResolvedValue() + daemonOwnsFreshPersistentPtysMock.mockReturnValue(true) + readDaemonPidRecordMock.mockReturnValue({ pid: 4242 }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('startOrcadDaemon', () => { + it('reports live with the daemon pid once the provider is installed', async () => { + await expect(startOrcadDaemon()).resolves.toEqual({ state: 'live', pid: 4242 }) + }) + + it('does not arm the macOS login-session death watch', async () => { + await startOrcadDaemon() + // That watch retires the daemon when the spawning GUI login session dies. An orcad + // daemon must survive its SSH session ending — arming it would kill every terminal the + // moment the operator logged out, which is the opposite of the property being bought. + expect(initDaemonPtyProviderMock).toHaveBeenCalledWith(undefined, { + macosLoginSessionWatch: false + }) + }) + + it('reports degraded when fresh terminals would fall back to the local provider', async () => { + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + const result = await startOrcadDaemon() + expect(result.state).toBe('degraded') + }) + + it('fails open when the daemon cannot start at all', async () => { + initDaemonPtyProviderMock.mockRejectedValue(new Error('node-pty is missing')) + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + // Fail-open, like the desktop: git, worktrees and non-persistent terminals must still + // serve. What must not happen is a thrown startup or a claim of persistence. + await expect(startOrcadDaemon()).resolves.toEqual({ + state: 'unavailable', + reason: 'node-pty is missing' + }) + }) +}) + +describe('stopOrcadDaemon', () => { + it('disconnects and never shuts the daemon down', async () => { + await stopOrcadDaemon() + expect(disconnectDaemonMock).toHaveBeenCalledTimes(1) + // The whole point of item 4: an orcad restart is non-destructive only if the daemon + // outlives it. shutdownDaemon() kills the daemon and every PTY under it. + expect(shutdownDaemonMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-daemon-supervision.ts b/src/main/orcad/orcad-daemon-supervision.ts new file mode 100644 index 00000000000..d35b03048d3 --- /dev/null +++ b/src/main/orcad/orcad-daemon-supervision.ts @@ -0,0 +1,67 @@ +/** + * orcad's half of the supervision contract: it spawns and supervises the terminal daemon. + * + * The whole reason the peer model is recommended over an SSH target is that daemon-backed + * PTYs stay `live` across a runtime restart (docs/reference/ssh-execution-boundary.md). + * Without a daemon here, every orcad restart, update and rollback is a SIGKILL for every + * running terminal — on the host whose selling point is that work survives the client going + * away. + * + * The constraint that makes a restart non-destructive lives in `stopOrcadDaemon` below: the + * daemon is DETACHED and must outlive this process. Anything that tears it down on the way + * out silently converts a restart back into data loss. + */ +import { + disconnectDaemon, + daemonOwnsFreshPersistentPtys, + initDaemonPtyProvider, + readDaemonPidRecord +} from '../daemon/daemon-init' + +export type OrcadDaemonStartup = + | { state: 'live'; pid: number | null } + | { state: 'degraded'; reason: string } + | { state: 'unavailable'; reason: string } + +/** + * Bring the daemon up and install it as the local PTY provider. + * + * Fail-open, like the desktop: a host that cannot start a daemon must still serve git, + * worktrees and non-persistent terminals. What it must NOT do is keep claiming persistence + * — `daemonOwnsFreshPersistentPtys()` is what the runtime reads for that, and it answers + * false here without any extra bookkeeping. + */ +export async function startOrcadDaemon(): Promise { + try { + // Why no login-session watch: that retires the daemon when the spawning macOS GUI login + // session dies. An orcad daemon must survive its SSH session ending — that is the point. + await initDaemonPtyProvider(undefined, { macosLoginSessionWatch: false }) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[orcad] The terminal daemon did not start: ${reason}\n` + + '[orcad] Terminals will run in-process and WILL NOT survive an orcad restart.' + ) + return { state: 'unavailable', reason } + } + if (!daemonOwnsFreshPersistentPtys()) { + const reason = 'daemon adopted in degraded mode; fresh terminals run on the local provider' + console.warn( + `[orcad] ${reason}. Existing daemon sessions keep working, but new terminals will not ` + + 'survive an orcad restart until the daemon is restarted.' + ) + return { state: 'degraded', reason } + } + return { state: 'live', pid: readDaemonPidRecord()?.pid ?? null } +} + +/** + * Release the daemon without killing it. + * + * Why `disconnectDaemon` and never `shutdownDaemon`: shutdown kills the daemon process and + * every PTY under it. Calling it here would make orcad's own restart destructive, which is + * the exact property this whole item exists to buy. + */ +export async function stopOrcadDaemon(): Promise { + await disconnectDaemon() +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 6afdcdb50e3..4af2a87c45d 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -18,6 +18,16 @@ import type { ServeReadiness } from '../server/serve-readiness' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' +import { + describeOrcadBindExposure, + OrcadBindAddressError, + resolveOrcadBindHost +} from './orcad-bind-address' +import { + acquireOrcadInstanceLock, + OrcadInstanceLockError, + type OrcadInstanceLock +} from './orcad-instance-lock' let runOrcadQuitHandlers = (): void => {} @@ -80,6 +90,8 @@ export type OrcadOptions = { json?: boolean noPairing?: boolean pairingAddress?: string + /** Literal IP to bind. Defaults to loopback; see orcad-bind-address.ts. */ + bind?: string } export type OrcadHandle = { @@ -95,24 +107,30 @@ export type OrcadHandle = { export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() const userDataPath = resolveUserDataPath() + // Why before anything else touches the root: the profile index, the store and the daemon + // runtime dir all live under it, and two orcads sharing them corrupt state silently. This + // is also the last point at which refusing costs nothing. + const instanceLock = acquireOrcadInstanceLock(userDataPath) const browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) setRuntimeBrowserCommandsFactory(browserProvider?.factory ?? null, { headless: browserProvider !== null, ...(browserProvider ? { isAvailable: () => browserProvider.isAvailable() } : {}) }) try { - return await startOrcadRuntime(options, browserProvider) + return await startOrcadRuntime(options, browserProvider, instanceLock) } catch (error) { await browserProvider?.stop() setRuntimeBrowserCommandsFactory(null) runOrcadQuitHandlers() + instanceLock.release() throw error } } async function startOrcadRuntime( options: OrcadOptions, - browserProvider: OrcadBrowserProvider | null + browserProvider: OrcadBrowserProvider | null, + instanceLock: OrcadInstanceLock ): Promise { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') @@ -125,6 +143,9 @@ async function startOrcadRuntime( const { ensureActiveOrcaProfile, initOrcaProfilePaths } = await import('../orca-profiles/profile-index-store') const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store') + const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') + const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') + const { collectOrcadHealth } = await import('./orcad-health') const runtimeUserDataPath = getAppEnvironment().getPath('userData') initOrcaProfilePaths() @@ -139,6 +160,11 @@ async function startOrcadRuntime( // which is safe but silently discards accept records on every launch. initSshHostKeyStoreFile(profile.dataFile) + // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon + // adapter as THE local provider, and the registry's contract is that it lands before + // registerPtyHandlers so the IPC layer routes through the daemon from the first call. + await startOrcadDaemon() + const runtime = new OrcaRuntimeService(store, undefined, { // Why lazy: a daemon swap replaces the provider after construction, so an eager // reference would freeze the pre-daemon one. @@ -146,10 +172,11 @@ async function startOrcadRuntime( // Why: destructive worktree removal refuses to run without a provider to stop // processes through — correctly, since it cannot otherwise verify the tree is idle. getSshProvider: (connectionId) => getSshPtyProvider(connectionId), - // Why false: this host does not run the terminal daemon, so persistent local PTYs - // cannot be recovered. The constructor defaults this to true, which would claim a - // capability orcad does not have. - canRecoverPersistentLocalPtys: () => false, + // Why the daemon predicate and not a constant: orcad now spawns the terminal daemon, so + // its PTYs DO survive an orcad restart — but only while a daemon that owns fresh + // sessions is installed. A failed or degraded launch has to answer false, and this reads + // that live rather than snapshotting it at construction. + canRecoverPersistentLocalPtys: () => daemonOwnsFreshPersistentPtys(), // Why 'blocked': `'openable'` means a desktop window can be opened here, which is // what powers serve→desktop promotion. A Node host can never do that, and the // constructor's default would advertise it. @@ -173,14 +200,20 @@ async function startOrcadRuntime( await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() + const bindHost = resolveOrcadBindHost(options.bind) const rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, enableWebSocket: true, - exposeNetworkByDefault: true, + // Why pinned and not `exposeNetworkByDefault`: an unattended host's exposure must be + // exactly what the operator asked for, on every launch. The default path widens itself + // once a device has connected, so a loopback deployment would silently go wide one + // restart after its first client paired. + pinnedBindHost: bindHost, ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) }) await rpc.start() + console.error(`[orcad] ${describeOrcadBindExposure(bindHost)}`) const boundEndpoint = rpc.getWebSocketEndpoint() const advertised = boundEndpoint @@ -215,7 +248,11 @@ async function startOrcadRuntime( scope: 'runtime', qr: null } - : offer + : offer, + // Why in the readiness payload: this is the one message a supervisor and a deploy + // transaction both read, and a green orcad with a dead daemon is exactly the + // looks-healthy-but-useless state they must not activate. + health: await collectOrcadHealth(getAppEnvironment().getVersion()) } await new ServeReadinessPublisher().publish(readiness, { @@ -228,15 +265,20 @@ async function startOrcadRuntime( try { await rpc.stop() } finally { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. See + // orcad-daemon-supervision.ts. + await stopOrcadDaemon() await browserProvider?.stop() setRuntimeBrowserCommandsFactory(null) runOrcadQuitHandlers() + instanceLock.release() } } } } -function parseArgs(argv: string[]): OrcadOptions { +export function parseArgs(argv: string[]): OrcadOptions { const options: OrcadOptions = {} for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] @@ -252,6 +294,13 @@ function parseArgs(argv: string[]): OrcadOptions { options.json = true } else if (arg === '--no-pairing') { options.noPairing = true + } else if (arg === '--bind') { + const value = argv[i + 1] + if (value === undefined) { + throw new Error('--bind expects a value') + } + options.bind = value + i += 1 } else if (arg === '--pairing-address') { const value = argv[i + 1] if (!value) { @@ -266,22 +315,56 @@ function parseArgs(argv: string[]): OrcadOptions { return options } +/** + * Exit codes a supervisor can act on. Closed set — see docs/reference/orcad-operations.md. + * + * `ORCAD_EXIT_CONFIGURATION` is the load-bearing one: a data root owned by someone else, or + * held by another orcad, is not fixed by restarting. Restarting on it is the crash-loop the + * supervision contract has to prevent, so systemd's `RestartPreventExitStatus` needs a code + * that means "do not retry" and nothing else does. + */ +export const ORCAD_EXIT_OK = 0 +export const ORCAD_EXIT_FAILED = 1 +export const ORCAD_EXIT_CONFIGURATION = 78 + +/** Bounded so a wedged transport cannot hold a supervisor's stop past its own deadline. */ +export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 + +export function resolveOrcadExitCode(error: unknown): number { + return error instanceof OrcadInstanceLockError || error instanceof OrcadBindAddressError + ? ORCAD_EXIT_CONFIGURATION + : ORCAD_EXIT_FAILED +} + export async function main(argv: string[] = process.argv.slice(2)): Promise { const handle = await startOrcad(parseArgs(argv)) let stopping = false const shutdown = (signal: NodeJS.Signals): void => { if (stopping) { - return + // Why escalate rather than ignore: a supervisor's second signal means the first + // deadline elapsed. Continuing to wait silently is what makes a stop hang until + // SIGKILL, which is the one teardown that skips the daemon handoff entirely. + console.error(`orcad: second ${signal} during shutdown — exiting immediately`) + process.exit(ORCAD_EXIT_FAILED) } stopping = true + // Why a self-imposed deadline as well: the supervisor's SIGKILL leaves no exit code and + // no log line. Exiting ourselves keeps the failure attributable. + const deadline = setTimeout(() => { + console.error( + `orcad: shutdown after ${signal} exceeded ${ORCAD_SHUTDOWN_DEADLINE_MS}ms — exiting` + ) + process.exit(ORCAD_EXIT_FAILED) + }, ORCAD_SHUTDOWN_DEADLINE_MS) + deadline.unref() handle .stop() - .then(() => process.exit(0)) + .then(() => process.exit(ORCAD_EXIT_OK)) // Why not rethrow: we are already tearing down on a signal, and an exit code is // the only thing a supervisor can act on. .catch((error) => { console.error(`orcad: shutdown after ${signal} failed:`, error) - process.exit(1) + process.exit(ORCAD_EXIT_FAILED) }) } process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts new file mode 100644 index 00000000000..7a1a2a269ff --- /dev/null +++ b/src/main/orcad/orcad-health.test.ts @@ -0,0 +1,152 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedDaemonPid } from '../daemon/daemon-pid-file-parse' +import type { DaemonHealth } from '../daemon/daemon-health' + +const { + checkDaemonHealthMock, + getDaemonEndpointFactsMock, + readDaemonPidRecordMock, + daemonOwnsFreshPersistentPtysMock +} = vi.hoisted(() => ({ + checkDaemonHealthMock: vi.fn<() => Promise>(), + getDaemonEndpointFactsMock: vi.fn<() => unknown>(), + readDaemonPidRecordMock: vi.fn<() => ParsedDaemonPid | null>(), + daemonOwnsFreshPersistentPtysMock: vi.fn<() => boolean>() +})) + +vi.mock('../daemon/daemon-health', () => ({ checkDaemonHealth: checkDaemonHealthMock })) +vi.mock('../daemon/daemon-init', () => ({ + getDaemonEndpointFacts: getDaemonEndpointFactsMock, + readDaemonPidRecord: readDaemonPidRecordMock, + daemonOwnsFreshPersistentPtys: daemonOwnsFreshPersistentPtysMock +})) + +const { collectOrcadHealth, collectTerminalDaemonHealth, computeOrcadBuildHash } = + await import('./orcad-health') + +const LIVE_FACTS = { + runtimeDir: '/data/daemon', + socketPath: '/data/daemon/daemon-v36.sock', + tokenPath: '/data/daemon/daemon-v36.token', + pidPath: '/data/daemon/daemon-v36.pid', + protocolVersion: 36 +} + +const PID_RECORD: ParsedDaemonPid = { + pid: 4242, + startedAtMs: 1_000, + entryPath: '/opt/orcad/daemon-entry.js', + appVersion: '1.2.2', + launchNonce: 'n', + linuxStartTicks: null, + bootId: null, + spawnerExecPath: null +} + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + +beforeEach(() => { + getDaemonEndpointFactsMock.mockReturnValue(LIVE_FACTS) + readDaemonPidRecordMock.mockReturnValue(PID_RECORD) + daemonOwnsFreshPersistentPtysMock.mockReturnValue(true) + checkDaemonHealthMock.mockResolvedValue('healthy') +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + vi.clearAllMocks() +}) + +describe('collectTerminalDaemonHealth', () => { + it('reports live only when the daemon answered its own PTY spawn probe', async () => { + const health = await collectTerminalDaemonHealth() + expect(health.state).toBe('live') + expect(health.selfTest).toMatchObject({ ok: true, verdict: 'healthy', coverage: 'pty-spawn' }) + expect(health.pid).toBe(4242) + // The build the LIVE daemon came from, which can legitimately predate this orcad. + expect(health.buildVersion).toBe('1.2.2') + expect(health.entryPath).toBe('/opt/orcad/daemon-entry.js') + expect(health.protocolVersion).toBe(36) + // Why assert the coordinates: a self-test that probed some other endpoint would prove + // nothing about the daemon this process installed. + expect(checkDaemonHealthMock).toHaveBeenCalledWith(LIVE_FACTS.socketPath, LIVE_FACTS.tokenPath) + }) + + it('is not green when the daemon is up but cannot spawn a PTY', async () => { + checkDaemonHealthMock.mockResolvedValue('pty-spawn-unhealthy') + const health = await collectTerminalDaemonHealth() + expect(health.selfTest.ok).toBe(false) + expect(health.selfTest.verdict).toBe('pty-spawn-unhealthy') + // Degraded, not absent: it still owns live sessions, and calling those exited would be + // the verdict the execution-boundary vocabulary forbids guessing. + expect(health.state).toBe('degraded') + }) + + it('is not green when the daemon stopped answering entirely', async () => { + checkDaemonHealthMock.mockResolvedValue('unreachable') + const health = await collectTerminalDaemonHealth() + expect(health.selfTest.ok).toBe(false) + expect(health.state).toBe('degraded') + }) + + it('is not green when fresh terminals fall back to the local provider', async () => { + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + const health = await collectTerminalDaemonHealth() + // The socket answers and the probe passes, but new terminals would die with this + // process — reporting live here is precisely the looks-healthy-but-useless shape. + expect(health.selfTest.ok).toBe(true) + expect(health.ownsFreshSessions).toBe(false) + expect(health.state).toBe('degraded') + }) + + it('reports absent, and probes nothing, when no daemon was ever installed', async () => { + getDaemonEndpointFactsMock.mockReturnValue(null) + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + const health = await collectTerminalDaemonHealth() + expect(health.state).toBe('absent') + expect(health.selfTest).toMatchObject({ ok: false, verdict: 'no-daemon' }) + expect(health.pid).toBeNull() + expect(checkDaemonHealthMock).not.toHaveBeenCalled() + }) + + it('declares handshake-only coverage on win32, where the spawn probe is a no-op', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const health = await collectTerminalDaemonHealth() + // `checkPtySpawnHealth` returns immediately on win32 without spawning anything, so a + // green verdict there must not be reported as a PTY round trip. + expect(health.selfTest.coverage).toBe('handshake') + }) +}) + +describe('collectOrcadHealth', () => { + it('carries build identity and the Node ABI native addons must match', async () => { + const health = await collectOrcadHealth('1.2.3') + expect(health.buildVersion).toBe('1.2.3') + expect(health.nodeVersion).toBe(process.versions.node) + expect(health.nodeAbi).toBe(process.versions.modules) + expect(health.platform).toBe(process.platform) + expect(health.terminalDaemon.state).toBe('live') + }) +}) + +describe('computeOrcadBuildHash', () => { + it('changes when the bundle bytes change, even at the same version string', () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-build-hash-')) + const entry = join(dir, 'orcad.js') + writeFileSync(entry, 'build-a') + const first = computeOrcadBuildHash(entry) + writeFileSync(entry, 'build-b') + // A rollback that did not actually replace the file is what this has to catch, and a + // version string cannot. + expect(computeOrcadBuildHash(entry)).not.toBe(first) + }) + + it('answers unknown rather than throwing when the entry cannot be read', () => { + expect(computeOrcadBuildHash(join(tmpdir(), 'definitely-absent-orcad.js'))).toBe('unknown') + // A process with no argv[1] (an embedded host) still has to publish a readiness payload. + expect(computeOrcadBuildHash('')).toBe('unknown') + }) +}) diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts new file mode 100644 index 00000000000..9f271c55d84 --- /dev/null +++ b/src/main/orcad/orcad-health.ts @@ -0,0 +1,155 @@ +/** + * orcad's health surface: the facts a supervisor needs to decide whether this deployment + * is actually serving, as opposed to merely listening. + * + * The load-bearing one is the terminal-daemon verdict. orcad answers RPC from its own + * process, so "the port is open" stays true while the daemon that owns every terminal is + * dead — a green host that cannot run a single command. The self-test below therefore has + * to cross the process boundary: orcad drives it, the daemon performs it, and the verdict + * travels back over the daemon's socket. + */ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { checkDaemonHealth, type DaemonHealth } from '../daemon/daemon-health' +import { + daemonOwnsFreshPersistentPtys, + getDaemonEndpointFacts, + readDaemonPidRecord +} from '../daemon/daemon-init' + +/** + * How much a green self-test actually proves. + * + * `pty-spawn` — the daemon spawned a real PTY inside its own process and it worked. + * `handshake` — the daemon answered its protocol handshake, but its spawn probe is a no-op + * on this platform (win32: `checkPtySpawnHealth` returns without spawning). Reported + * separately rather than folded into `ok`, because claiming a PTY round trip we did not + * perform is the failure mode this surface exists to prevent. + */ +export type PtySelfTestCoverage = 'pty-spawn' | 'handshake' + +export type PtySelfTest = { + ok: boolean + coverage: PtySelfTestCoverage + /** The daemon's own verdict word, so a failure is diagnosable without re-probing. */ + verdict: DaemonHealth | 'no-daemon' + durationMs: number +} + +export type TerminalDaemonHealth = { + /** `live` requires the daemon to have answered; absence is never inferred from silence. */ + state: 'live' | 'degraded' | 'absent' + /** True only when FRESH terminals are daemon-owned, i.e. survive an orcad restart. */ + ownsFreshSessions: boolean + pid: number | null + /** The build the LIVE daemon was forked from, which may predate this orcad after an update. */ + buildVersion: string | null + entryPath: string | null + protocolVersion: number | null + selfTest: PtySelfTest +} + +export type OrcadHealth = { + /** Content hash of the running orcad bundle — the deployed build's identity. */ + buildHash: string + buildVersion: string + nodeVersion: string + /** `process.versions.modules`: the ABI every native addon on this host must match. */ + nodeAbi: string + platform: NodeJS.Platform + arch: string + pid: number + terminalDaemon: TerminalDaemonHealth +} + +/** + * Identity of the exact bytes running. + * + * Why hash the entry and not read a version string: `ORCA_VERSION` is whatever the deploy + * exported, so two different builds can carry one version. A rollback that did not actually + * replace the file is precisely what this has to catch. + */ +export function computeOrcadBuildHash(entryPath = process.argv[1]): string { + if (!entryPath) { + return 'unknown' + } + try { + return createHash('sha256').update(readFileSync(entryPath)).digest('hex').slice(0, 16) + } catch { + return 'unknown' + } +} + +/** + * Probe the daemon across the process boundary. + * + * `checkDaemonHealth` is the cross-process test: it opens the daemon's socket, completes the + * protocol handshake, and asks the daemon to run `ptySpawnHealth` — a real short-lived PTY + * spawned inside the daemon. Only a daemon that is alive AND can create terminals answers + * `healthy`; a wedged one times out to `unreachable`, and one whose node-pty or login session + * is broken answers `pty-spawn-unhealthy`. + */ +export async function runTerminalDaemonSelfTest( + now: () => number = () => Date.now() +): Promise { + const startedAt = now() + // Why: `checkPtySpawnHealth` returns immediately on win32 without spawning anything, so a + // green verdict there covers the handshake only. Say so instead of overclaiming. + const coverage: PtySelfTestCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' + const facts = getDaemonEndpointFacts() + if (!facts) { + return { ok: false, coverage, verdict: 'no-daemon', durationMs: now() - startedAt } + } + const verdict = await checkDaemonHealth(facts.socketPath, facts.tokenPath) + return { ok: verdict === 'healthy', coverage, verdict, durationMs: now() - startedAt } +} + +export async function collectTerminalDaemonHealth(): Promise { + const facts = getDaemonEndpointFacts() + const selfTest = await runTerminalDaemonSelfTest() + if (!facts) { + return { + state: 'absent', + ownsFreshSessions: false, + pid: null, + buildVersion: null, + entryPath: null, + protocolVersion: null, + selfTest + } + } + const record = readDaemonPidRecord() + const ownsFreshSessions = daemonOwnsFreshPersistentPtys() + return { + // Why `degraded` and not `absent` on a failed self-test: a daemon that answered its + // handshake but failed the spawn probe is still holding live sessions. Reporting it gone + // would invite a caller to treat those terminals as exited, which is the one verdict the + // execution-boundary vocabulary forbids guessing. + state: + selfTest.ok && ownsFreshSessions + ? 'live' + : selfTest.verdict === 'no-daemon' + ? 'absent' + : 'degraded', + ownsFreshSessions, + pid: record?.pid ?? null, + buildVersion: record?.appVersion ?? null, + entryPath: record?.entryPath ?? null, + protocolVersion: facts.protocolVersion, + selfTest + } +} + +export async function collectOrcadHealth(buildVersion: string): Promise { + return { + buildHash: computeOrcadBuildHash(), + buildVersion, + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + platform: process.platform, + arch: process.arch, + pid: process.pid, + terminalDaemon: await collectTerminalDaemonHealth() + } +} diff --git a/src/main/orcad/orcad-instance-lock.test.ts b/src/main/orcad/orcad-instance-lock.test.ts new file mode 100644 index 00000000000..45f7543c515 --- /dev/null +++ b/src/main/orcad/orcad-instance-lock.test.ts @@ -0,0 +1,151 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + acquireOrcadInstanceLock, + ORCAD_LOCK_FILE_NAME, + OrcadInstanceLockError, + type OrcadInstanceLockHooks +} from './orcad-instance-lock' + +const roots: string[] = [] + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'orcad-lock-')) + roots.push(root) + return root +} + +/** Deterministic identity/liveness so the assertions do not depend on this machine's pids. */ +function hooks(overrides: OrcadInstanceLockHooks = {}): OrcadInstanceLockHooks { + return { + identity: () => 'uid-1000', + version: () => '1.0.0-test', + startedAtMs: () => 1_000, + startTimeMatches: () => true, + processIsAlive: () => false, + ...overrides + } +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('acquireOrcadInstanceLock', () => { + it('publishes a record naming the holder and removes it on release', () => { + const root = makeRoot() + const lock = acquireOrcadInstanceLock(root, hooks()) + const record = JSON.parse(readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')) + expect(record.pid).toBe(process.pid) + expect(record.identity).toBe('uid-1000') + expect(record.version).toBe('1.0.0-test') + lock.release() + expect(() => readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')).toThrow() + }) + + it('refuses a second instance while the holder is alive', () => { + const root = makeRoot() + acquireOrcadInstanceLock(root, hooks()) + expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => true }))).toThrow( + OrcadInstanceLockError + ) + expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => true }))).toThrow( + expect.objectContaining({ code: 'orcad_instance_lock_held' }) + ) + }) + + it('reclaims the record of a holder that is gone', () => { + const root = makeRoot() + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-1000', startedAtMs: 1, nonce: 'stale' }) + ) + const lock = acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => false })) + expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) + }) + + it('treats a live pid whose start time does not match as a recycled pid, not a holder', () => { + const root = makeRoot() + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-1000', startedAtMs: 1, nonce: 'stale' }) + ) + const lock = acquireOrcadInstanceLock( + root, + hooks({ processIsAlive: () => true, startTimeMatches: () => false }) + ) + expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) + }) + + it('never reclaims a lock held by a different identity, even a dead one', () => { + const root = makeRoot() + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-2000', startedAtMs: 1, nonce: 'other' }) + ) + expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => false }))).toThrow( + expect.objectContaining({ code: 'orcad_instance_lock_foreign_identity' }) + ) + }) + + it('does not delete a record that a later instance already replaced', () => { + const root = makeRoot() + const lock = acquireOrcadInstanceLock(root, hooks()) + // A successor reclaimed the root while this process was wedged. + writeFileSync( + lock.path, + JSON.stringify({ pid: 777, identity: 'uid-1000', startedAtMs: 2, nonce: 'successor' }) + ) + lock.release() + expect(JSON.parse(readFileSync(lock.path, 'utf8')).nonce).toBe('successor') + }) + + it.runIf(process.platform !== 'win32')( + 'tightens a group/world-accessible data root rather than refusing when it can', + () => { + const root = makeRoot() + chmodSync(root, 0o755) + acquireOrcadInstanceLock(root, hooks()) + expect(statSync(root).mode & 0o777).toBe(0o700) + } + ) + + it.runIf(process.platform !== 'win32')('refuses a data root owned by another uid', () => { + const root = makeRoot() + // /tmp itself is root-owned and sticky on every supported platform, so it stands in for + // "a data root this process does not own" without needing privileges to create one. + expect(() => acquireOrcadInstanceLock('/tmp', hooks())).toThrow( + expect.objectContaining({ code: 'orcad_data_root_wrong_owner' }) + ) + // And the private root this test made is still acceptable, so the refusal is about + // ownership rather than a blanket rejection. + expect(acquireOrcadInstanceLock(root, hooks()).record.identity).toBe('uid-1000') + }) + + it('leaves the terminal daemon alone: the lock covers only the runtime role', () => { + const root = makeRoot() + // The daemon lives here and deliberately outlives the runtime. Releasing the runtime's + // lock must not touch it, or a restart would stop being non-destructive. + const daemonDir = join(root, 'daemon') + mkdirSync(daemonDir, { recursive: true }) + writeFileSync(join(daemonDir, 'daemon-v36.pid'), JSON.stringify({ pid: 99, startedAtMs: 1 })) + const lock = acquireOrcadInstanceLock(root, hooks()) + lock.release() + expect(JSON.parse(readFileSync(join(daemonDir, 'daemon-v36.pid'), 'utf8')).pid).toBe(99) + // And a fresh instance takes the root back while that daemon record still stands. + const next = acquireOrcadInstanceLock(root, hooks()) + expect(next.record.pid).toBe(process.pid) + }) +}) diff --git a/src/main/orcad/orcad-instance-lock.ts b/src/main/orcad/orcad-instance-lock.ts new file mode 100644 index 00000000000..052140bcd2e --- /dev/null +++ b/src/main/orcad/orcad-instance-lock.ts @@ -0,0 +1,324 @@ +/** + * Single-instance ownership of orcad's data root, taken BEFORE the profile is loaded. + * + * Two orcads on one data root corrupt it quietly: both load the same profile, both write + * the same store file, and the loser's writes disappear on the next flush. The refusal is + * therefore a startup gate, not a warning. + * + * What this lock does NOT cover, and must not: the terminal daemon. The daemon is a second + * long-lived process living under `/daemon`, it deliberately outlives the orcad that + * spawned it, and it fences its own endpoint with a PID record of its own. A lock that + * asked "is any process using this root" would refuse every restart that a live daemon + * makes worthwhile. This lock scopes exactly one role — who is the runtime — so releasing + * it says nothing about the daemon, which is what makes a non-destructive restart possible. + */ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { userInfo } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { getProcessStartedAtMs, startTimeMatches } from '../daemon/daemon-process-start-time' + +export const ORCAD_LOCK_FILE_NAME = 'orcad.lock' + +export type OrcadInstanceLockCode = + | 'orcad_data_root_unusable' + | 'orcad_data_root_wrong_owner' + | 'orcad_data_root_shared' + | 'orcad_instance_lock_held' + | 'orcad_instance_lock_foreign_identity' + +export class OrcadInstanceLockError extends Error { + constructor( + readonly code: OrcadInstanceLockCode, + message: string + ) { + super(message) + this.name = 'OrcadInstanceLockError' + } +} + +export type OrcadLockRecord = { + pid: number + /** Null where the platform cannot read it; PID alone is then the (weaker) fence. */ + startedAtMs: number | null + /** POSIX uid, or the Windows username. Compared as an opaque string. */ + identity: string + version: string + acquiredAt: string + /** Distinguishes our record from a replacement written after we lost the race. */ + nonce: string +} + +export type OrcadInstanceLock = { + readonly path: string + readonly record: OrcadLockRecord + release(): void +} + +export type OrcadInstanceLockHooks = { + identity?: () => string + version?: () => string + now?: () => Date + /** Whether a PID is running. EPERM counts as alive: it proves the process exists. */ + processIsAlive?: (pid: number) => boolean + startedAtMs?: (pid: number) => number | null + startTimeMatches?: (pid: number, expected: number | null) => boolean +} + +function defaultIdentity(): string { + // Why uid and not the name on POSIX: two accounts can share a login name across a + // container boundary while the uid is what the filesystem actually enforces. + return process.platform === 'win32' + ? (userInfo().username ?? 'unknown') + : String(process.getuid?.() ?? 'unknown') +} + +function defaultProcessIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return isErrorCode(error, 'EPERM') + } +} + +function isErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +function parseLockRecord(content: string): OrcadLockRecord | null { + try { + const parsed: unknown = JSON.parse(content) + if (!parsed || typeof parsed !== 'object') { + return null + } + const record = parsed as Partial + if (typeof record.pid !== 'number' || typeof record.identity !== 'string') { + return null + } + return { + pid: record.pid, + startedAtMs: typeof record.startedAtMs === 'number' ? record.startedAtMs : null, + identity: record.identity, + version: typeof record.version === 'string' ? record.version : 'unknown', + acquiredAt: typeof record.acquiredAt === 'string' ? record.acquiredAt : '', + nonce: typeof record.nonce === 'string' ? record.nonce : '' + } + } catch { + return null + } +} + +/** + * Fail closed on a data root other identities can read or write. + * + * Why self-heal first and refuse second: orcad stores credentials unsealed (there is no OS + * keyring on this host), so a group- or world-accessible root is a real exposure — but if + * we own the directory, tightening it is strictly better than refusing to start. We refuse + * only when the permissions are not ours to fix. + */ +function assertDataRootIsPrivate(dataRoot: string): void { + // Windows ACLs are not expressible as a POSIX mode, and `statSync().mode` there reports a + // synthesized one. Checking it would refuse correct deployments and pass wrong ones. + if (process.platform === 'win32') { + return + } + let stats + try { + stats = statSync(dataRoot) + } catch (error) { + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}` + ) + } + const uid = process.getuid?.() + if (uid !== undefined && stats.uid !== uid) { + throw new OrcadInstanceLockError( + 'orcad_data_root_wrong_owner', + `The orcad data root ${dataRoot} is owned by uid ${stats.uid}, not by uid ${uid} running ` + + 'this process. Give orcad its own data root (ORCA_USER_DATA) or chown this one.' + ) + } + if ((stats.mode & 0o077) === 0) { + return + } + try { + chmodSync(dataRoot, 0o700) + } catch { + // Fall through to the re-stat, which produces the actionable message. + } + let mode: number + try { + mode = statSync(dataRoot).mode + } catch (error) { + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}` + ) + } + if ((mode & 0o077) !== 0) { + throw new OrcadInstanceLockError( + 'orcad_data_root_shared', + `The orcad data root ${dataRoot} is accessible to other users (mode ` + + `${(mode & 0o777).toString(8)}) and could not be tightened. orcad stores credentials ` + + 'there unsealed, so it refuses to start. Run `chmod 700` on it, or point ORCA_USER_DATA ' + + 'at a private directory.' + ) + } +} + +/** + * Take the lock, or throw an `OrcadInstanceLockError` naming why. + * + * A dead holder's record is reclaimed; a live one, or one belonging to a different identity, + * is never touched. + */ +export function acquireOrcadInstanceLock( + dataRoot: string, + hooks: OrcadInstanceLockHooks = {} +): OrcadInstanceLock { + const identity = (hooks.identity ?? defaultIdentity)() + const isAlive = hooks.processIsAlive ?? defaultProcessIsAlive + const readStartedAt = hooks.startedAtMs ?? getProcessStartedAtMs + const matchesStartTime = hooks.startTimeMatches ?? startTimeMatches + + try { + mkdirSync(dataRoot, { recursive: true, mode: 0o700 }) + } catch (error) { + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot create the orcad data root ${dataRoot}: ${(error as Error).message}` + ) + } + assertDataRootIsPrivate(dataRoot) + + const lockPath = join(dataRoot, ORCAD_LOCK_FILE_NAME) + const record: OrcadLockRecord = { + pid: process.pid, + startedAtMs: readStartedAt(process.pid), + identity, + version: (hooks.version ?? (() => process.env.ORCA_VERSION ?? 'unknown'))(), + acquiredAt: (hooks.now ?? (() => new Date()))().toISOString(), + nonce: randomUUID() + } + const serialized = JSON.stringify(record) + + const publish = (): boolean => { + try { + writeFileSync(lockPath, serialized, { flag: 'wx', mode: 0o600 }) + return true + } catch (error) { + if (isErrorCode(error, 'EEXIST')) { + return false + } + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot write the orcad instance lock ${lockPath}: ${(error as Error).message}` + ) + } + } + + if (publish()) { + return makeLock(lockPath, record) + } + + const existing = parseLockRecord(safeRead(lockPath) ?? '') + if (existing && existing.identity !== identity) { + throw new OrcadInstanceLockError( + 'orcad_instance_lock_foreign_identity', + `The orcad data root ${dataRoot} is locked by identity ${existing.identity} (pid ` + + `${existing.pid}); this process runs as ${identity}. Two identities sharing one data ` + + 'root corrupts it. Give each its own ORCA_USER_DATA.' + ) + } + if (existing && isAlive(existing.pid) && matchesStartTime(existing.pid, existing.startedAtMs)) { + throw new OrcadInstanceLockError( + 'orcad_instance_lock_held', + `Another orcad (pid ${existing.pid}, started ${existing.acquiredAt || 'unknown'}) already ` + + `owns the data root ${dataRoot}. Stop it before starting another, or use a different ` + + 'ORCA_USER_DATA.' + ) + } + if (!existing) { + console.warn( + `[orcad] The instance lock at ${lockPath} is unreadable; reclaiming it. If another orcad ` + + 'is running on this data root, stop it now.' + ) + } + + // Why rename-and-then-publish rather than unlink-and-write: rename claims one exact + // directory entry, so a replacement written between our read and our write stays at the + // canonical path and wins — we never delete a record we did not inspect. + const claimPath = `${lockPath}.stale-${process.pid}-${randomUUID()}` + try { + renameSync(lockPath, claimPath) + } catch { + throw new OrcadInstanceLockError( + 'orcad_instance_lock_held', + `Could not reclaim the stale orcad instance lock at ${lockPath}; another process is ` + + 'holding it. Retry, or stop the other orcad.' + ) + } + if (!publish()) { + // Someone else claimed it first. Their record is authoritative; ours is not. + try { + unlinkSync(claimPath) + } catch { + // A uniquely named claim is inert. + } + throw new OrcadInstanceLockError( + 'orcad_instance_lock_held', + `Another orcad took the data root ${dataRoot} while this one was reclaiming a stale lock.` + ) + } + try { + unlinkSync(claimPath) + } catch { + // The canonical record is authoritative; the claim is inert. + } + return makeLock(lockPath, record) +} + +function safeRead(path: string): string | null { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +function makeLock(lockPath: string, record: OrcadLockRecord): OrcadInstanceLock { + let released = false + return { + path: lockPath, + record, + release: () => { + if (released) { + return + } + released = true + // Why re-read before unlinking: a reclaim by a later orcad (after, say, a SIGKILL that + // this process somehow survived enough to run handlers) leaves a record that is not + // ours. Deleting it would unlock a live runtime. + const current = parseLockRecord(safeRead(lockPath) ?? '') + if (!current || current.nonce !== record.nonce) { + return + } + try { + unlinkSync(lockPath) + } catch { + // Best-effort: a leftover record with a dead pid is reclaimed on the next start. + } + } + } +} diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts new file mode 100644 index 00000000000..b22dc74f0e4 --- /dev/null +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -0,0 +1,43 @@ +/** + * The two things a supervisor reads off a launch: what the arguments mean, and what an exit + * code means. Both are part of the ops contract in docs/reference/orcad-operations.md. + */ +import { describe, expect, it } from 'vitest' +import { + ORCAD_EXIT_CONFIGURATION, + ORCAD_EXIT_FAILED, + parseArgs, + resolveOrcadExitCode +} from './orcad-entry' +import { OrcadBindAddressError } from './orcad-bind-address' +import { OrcadInstanceLockError } from './orcad-instance-lock' + +describe('parseArgs', () => { + it('accepts --bind and leaves it unset when absent', () => { + expect(parseArgs(['--bind', '0.0.0.0'])).toEqual({ bind: '0.0.0.0' }) + expect(parseArgs([])).toEqual({}) + expect(parseArgs(['--port', '6768', '--bind', '10.0.0.5', '--json'])).toEqual({ + port: 6768, + bind: '10.0.0.5', + json: true + }) + }) + + it('rejects --bind with no value rather than silently binding the default', () => { + expect(() => parseArgs(['--bind'])).toThrow('--bind expects a value') + expect(() => parseArgs(['--bind', '--json'])).not.toThrow() + }) +}) + +describe('resolveOrcadExitCode', () => { + it('separates a configuration fault from a generic failure', () => { + // A supervisor must be able to stop restarting on faults that restarting cannot fix: + // a data root owned by someone else, held by another instance, or a bad bind address. + expect( + resolveOrcadExitCode(new OrcadInstanceLockError('orcad_instance_lock_held', 'held')) + ).toBe(ORCAD_EXIT_CONFIGURATION) + expect(resolveOrcadExitCode(new OrcadBindAddressError('bad'))).toBe(ORCAD_EXIT_CONFIGURATION) + expect(resolveOrcadExitCode(new Error('port in use'))).toBe(ORCAD_EXIT_FAILED) + expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED) + }) +}) diff --git a/src/main/orcad/orcad-native-preflight.test.ts b/src/main/orcad/orcad-native-preflight.test.ts new file mode 100644 index 00000000000..6c697a3ecce --- /dev/null +++ b/src/main/orcad/orcad-native-preflight.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ORCAD_NATIVE_PRECONDITION_EXIT_CODE, + runOrcadNativePreflight +} from './orcad-native-preflight' +import { + runtimeTerminalDegradation, + setRuntimeTerminalUnavailableCause +} from '../runtime/native-terminal-availability' +import type { NodePtyPreconditionVerdict } from './node-pty-precondition' + +const ABI = { + platform: 'linux' as NodeJS.Platform, + arch: 'x64', + libc: 'glibc' as const, + glibcVersion: '2.31', + nodeAbi: '127' +} + +const verdict = (over: Partial): NodePtyPreconditionVerdict => ({ + status: 'ok', + slot: 'linux-x64-glibc', + abi: ABI, + ...over +}) + +const harness = (given: NodePtyPreconditionVerdict) => { + const warn = vi.fn() + const fail = vi.fn() + const exit = vi.fn(() => undefined as never) + const continued = runOrcadNativePreflight({ + check: () => given, + toolchainHints: () => [' sudo apt-get install -y build-essential python3'], + warn, + fail, + exit + }) + return { warn, fail, exit, continued } +} + +afterEach(() => { + setRuntimeTerminalUnavailableCause(null) +}) + +describe('runOrcadNativePreflight', () => { + it('stops the boot on a proven-unloadable binary instead of reaching the require', () => { + // Continuing here would hit the very dlopen the probe just proved fatal, and the + // operator would get the loader's stack trace instead of the sentence below. + const { fail, exit, warn } = harness( + verdict({ status: 'blocked', reason: 'libc_floor', detail: 'the binary requires GLIBC_2.34' }) + ) + + expect(exit).toHaveBeenCalledWith(ORCAD_NATIVE_PRECONDITION_EXIT_CODE) + expect(warn).not.toHaveBeenCalled() + const message = fail.mock.calls[0][0] as string + expect(message).toContain('newer C library') + expect(message).toContain('the binary requires GLIBC_2.34') + expect(message).toContain('libc glibc 2.31') + // The toolchain hint is what makes it actionable rather than merely accurate. + expect(message).toContain('sudo apt-get install -y build-essential python3') + }) + + it('exits with EX_CONFIG so a supervisor does not restart an unequippable host forever', () => { + expect(ORCAD_NATIVE_PRECONDITION_EXIT_CODE).toBe(78) + expect(ORCAD_NATIVE_PRECONDITION_EXIT_CODE).not.toBe(1) + }) + + it('publishes the blocked cause as a status degradation', () => { + harness(verdict({ status: 'blocked', reason: 'abi_mismatch', detail: 'built for ABI 115' })) + + expect(runtimeTerminalDegradation()).toEqual({ + code: 'terminal_unavailable', + capability: 'terminal.pty.v1', + reason: 'abi_mismatch', + detail: 'built for ABI 115', + message: + "This host's node-pty binary was built for a different Node ABI than the running Node, so it cannot be loaded. Rebuild node-pty against this Node version. (built for ABI 115)" + }) + }) + + it('boots on a spawn-time-only fault and reports it rather than refusing to serve', () => { + const { continued, exit, warn } = harness( + verdict({ status: 'degraded', reason: 'spawn_helper_missing', detail: 'no spawn-helper' }) + ) + + expect(continued).toBe(true) + expect(exit).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledOnce() + expect(runtimeTerminalDegradation()?.reason).toBe('spawn_helper_missing') + }) + + it('boots when the probe established nothing, because that is not evidence of a fault', () => { + const { continued, exit } = harness( + verdict({ status: 'unverifiable', reason: 'unknown', detail: 'probe timed out' }) + ) + + expect(continued).toBe(true) + expect(exit).not.toHaveBeenCalled() + // Still reported: an unverifiable host must not look identical to a proven-healthy one. + expect(runtimeTerminalDegradation()?.reason).toBe('unknown') + }) + + it('reports nothing when the load was proved good', () => { + setRuntimeTerminalUnavailableCause({ reason: 'load_failed' }) + + const { continued, warn, fail } = harness(verdict({ status: 'ok' })) + + expect(continued).toBe(true) + expect(warn).not.toHaveBeenCalled() + expect(fail).not.toHaveBeenCalled() + expect(runtimeTerminalDegradation()).toBeNull() + }) + + it('does not run the toolchain probe for a verdict that is not about a missing build', () => { + const toolchainHints = vi.fn(() => [] as string[]) + runOrcadNativePreflight({ + check: () => verdict({ status: 'degraded', reason: 'spawn_helper_missing' }), + toolchainHints, + warn: vi.fn(), + fail: vi.fn(), + exit: vi.fn(() => undefined as never) + }) + expect(toolchainHints).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-native-preflight.ts b/src/main/orcad/orcad-native-preflight.ts new file mode 100644 index 00000000000..0f3c2cf8970 --- /dev/null +++ b/src/main/orcad/orcad-native-preflight.ts @@ -0,0 +1,72 @@ +/** + * The native precondition orcad runs before its runtime loads anything native. + * + * Split from `node-pty-precondition.ts` so the decision (what to do about a verdict) is + * testable apart from the detection (what the verdict is). + */ +import process from 'node:process' +import { setRuntimeTerminalUnavailableCause } from '../runtime/native-terminal-availability' +import { terminalUnavailableMessage } from '../../shared/runtime-types' +import { + checkNodePtyPrecondition, + formatNodePtyPreconditionReport, + probeLocalBuildToolchainHints, + type NodePtyPreconditionVerdict +} from './node-pty-precondition' + +/** + * EX_CONFIG. Why not 1: a supervisor that restarts on 1 would restart forever against a + * host that can never load this binary. This code says "the host is not equipped", which + * is a different instruction from "it crashed". + */ +export const ORCAD_NATIVE_PRECONDITION_EXIT_CODE = 78 + +export type NativePreflightHooks = { + check?: () => NodePtyPreconditionVerdict + toolchainHints?: (platform: NodeJS.Platform) => string[] + warn?: (message: string) => void + fail?: (message: string) => void + exit?: (code: number) => never +} + +/** + * Returns true when boot may continue. + * + * A `blocked` verdict never returns: continuing would reach the very `require` the probe + * just proved fatal, and the operator would get the loader's stack trace instead of the + * sentence printed here. + */ +export function runOrcadNativePreflight(hooks: NativePreflightHooks = {}): boolean { + const check = hooks.check ?? checkNodePtyPrecondition + const warn = hooks.warn ?? ((message: string) => console.warn(message)) + const fail = hooks.fail ?? ((message: string) => console.error(message)) + const exit = hooks.exit ?? ((code: number) => process.exit(code) as never) + const verdict = check() + + if (verdict.status === 'ok') { + // Why clear rather than leave alone: a previous run in this process may have recorded + // a cause, and status.get must not keep reporting a degradation that no longer holds. + setRuntimeTerminalUnavailableCause(null) + return true + } + + const reason = verdict.reason ?? 'unknown' + setRuntimeTerminalUnavailableCause({ + reason, + ...(verdict.detail ? { detail: verdict.detail } : {}) + }) + const message = terminalUnavailableMessage(reason, verdict.detail) + + if (verdict.status === 'blocked') { + const hints = (hooks.toolchainHints ?? probeLocalBuildToolchainHints)(verdict.abi.platform) + fail(`orcad: ${formatNodePtyPreconditionReport(verdict, message, hints)}`) + exit(ORCAD_NATIVE_PRECONDITION_EXIT_CODE) + return false + } + + // `degraded` and `unverifiable` both boot. The first is a proven spawn-time fault the + // host can still serve around; the second established nothing, and refusing to boot on + // an inconclusive probe would take down hosts that work. + warn(`orcad: ${formatNodePtyPreconditionReport(verdict, message)}`) + return true +} diff --git a/src/main/ports/port-scan-command-client.test.ts b/src/main/ports/port-scan-command-client.test.ts index d1a15d29774..c2bece624f8 100644 --- a/src/main/ports/port-scan-command-client.test.ts +++ b/src/main/ports/port-scan-command-client.test.ts @@ -298,3 +298,29 @@ describe('PortScanCommandClient on a real worker thread', () => { } }, 30_000) }) + +describe('resolveWorkerEntryPath on a non-Electron host', () => { + // Why: orcad reports isPackaged true (it is a production build), but + // process.resourcesPath is Electron-only and undefined there. Joining undefined threw + // a TypeError instead of failing as a missing worker — a crash where a clean + // "worker unavailable" was the honest outcome. + it('does not join an undefined resourcesPath', () => { + expect(() => + resolveWorkerEntryPath({ + isPackaged: true, + resourcesPath: undefined, + moduleDir: '/opt/orcad' + }) + ).not.toThrow() + }) + + it('falls back to the module directory when there is no resources tree', () => { + expect( + resolveWorkerEntryPath({ + isPackaged: true, + resourcesPath: undefined, + moduleDir: '/opt/orcad' + }) + ).toBe(join('/opt/orcad', 'port-scan-command-worker-entry.js')) + }) +}) diff --git a/src/main/ports/port-scan-command-client.ts b/src/main/ports/port-scan-command-client.ts index c2bc80bc883..9c01b4dccda 100644 --- a/src/main/ports/port-scan-command-client.ts +++ b/src/main/ports/port-scan-command-client.ts @@ -303,7 +303,8 @@ const WORKER_ENTRY_FILENAME = 'port-scan-command-worker-entry.js' /** Where the built worker entry can live: packaged resources or the build dir. */ export type WorkerEntryLayout = { isPackaged: boolean - resourcesPath: string + /** Undefined on a non-Electron host: `process.resourcesPath` is Electron-only. */ + resourcesPath: string | undefined moduleDir: string } @@ -318,7 +319,12 @@ export function resolveWorkerEntryPath(layout: WorkerEntryLayout): string { // the bundler's __dirname, matching the shipped stt/warp/opencode workers. // Split out from the electron read so the packaged branch is testable without // a packaged build. - if (layout.isPackaged) { + // Why the resourcesPath guard: `isPackaged` is true on orcad too, but + // `process.resourcesPath` is Electron-only and undefined under plain Node — joining + // it threw a TypeError rather than failing as a missing worker. A host without an + // Electron resources tree has no asar to look in, so fall back to the module dir and + // let the caller report a missing worker honestly. + if (layout.isPackaged && layout.resourcesPath) { return join(layout.resourcesPath, 'app.asar', 'out', 'main', WORKER_ENTRY_FILENAME) } return join(layout.moduleDir, WORKER_ENTRY_FILENAME) diff --git a/src/main/runtime/native-terminal-availability.ts b/src/main/runtime/native-terminal-availability.ts new file mode 100644 index 00000000000..85eb3df57f0 --- /dev/null +++ b/src/main/runtime/native-terminal-availability.ts @@ -0,0 +1,51 @@ +import { + type RuntimeTerminalUnavailableReason, + terminalUnavailableMessage, + TERMINAL_PTY_DEGRADATION_CAPABILITY, + TERMINAL_UNAVAILABLE_ERROR_CODE, + type RuntimeDegradation +} from '../../shared/runtime-types' + +/** + * A note left by whoever proved this host cannot load or spawn PTYs, read back when + * `status.get` assembles `degradations[]`. + * + * Why a module-level note rather than a constructor argument: only the host entry point + * can run the out-of-process load probe, and it must run before anything requires + * `node-pty` — long before `OrcaRuntimeService` exists. This is the same shape + * `runtime-browser-commands-factory` uses for `browser_unavailable`, for the same reason. + * + * Silence means "nothing proved it broken", never "proved working". Hosts that never + * run a precondition therefore report no degradation, which is the honest answer. + */ +export type RuntimeTerminalUnavailableCause = { + reason: RuntimeTerminalUnavailableReason + detail?: string +} + +let unavailableCause: RuntimeTerminalUnavailableCause | null = null + +export function setRuntimeTerminalUnavailableCause( + cause: RuntimeTerminalUnavailableCause | null +): void { + unavailableCause = cause +} + +export function runtimeTerminalUnavailableCause(): RuntimeTerminalUnavailableCause | null { + return unavailableCause +} + +/** The degradation entry for the recorded cause, or null when nothing is degraded. */ +export function runtimeTerminalDegradation(): RuntimeDegradation | null { + const cause = unavailableCause + if (!cause) { + return null + } + return { + code: TERMINAL_UNAVAILABLE_ERROR_CODE, + capability: TERMINAL_PTY_DEGRADATION_CAPABILITY, + message: terminalUnavailableMessage(cause.reason, cause.detail), + reason: cause.reason, + ...(cause.detail ? { detail: cause.detail } : {}) + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 60926fc873c..40c9eafd621 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5,6 +5,7 @@ import { setRuntimeBrowserCommandsFactory, setRuntimeBrowserUnavailableCause } from './runtime-browser-commands-factory' +import { setRuntimeTerminalUnavailableCause } from './native-terminal-availability' import { setRuntimeDesktopSurface } from './runtime-desktop-surface' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type * as GitUsernameModule from '../git/git-username' @@ -697,6 +698,7 @@ function resetRuntimeTestMocks(): void { // browser RPCs reject rather than silently succeeding. setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) setRuntimeBrowserUnavailableCause(null) + setRuntimeTerminalUnavailableCause(null) // Why: the runtime's notification, window lookup and tab-create-reply channel are // injected now, so the electron mock alone is inert. Back the surface with the same // mocks so every existing expectation still holds. @@ -2769,6 +2771,43 @@ describe('OrcaRuntimeService', () => { ) }) + it('reports a host that cannot load node-pty, instead of that host never answering', () => { + // The alternative to reporting it is the process dying inside the dynamic loader, + // which reaches a client as a dropped connection with no cause attached. + setRuntimeTerminalUnavailableCause({ + reason: 'libc_floor', + detail: 'the binary requires GLIBC_2.34' + }) + + const degradations = createRuntime().getStatus().degradations ?? [] + + expect(degradations).toContainEqual({ + code: 'terminal_unavailable', + capability: 'terminal.pty.v1', + reason: 'libc_floor', + detail: 'the binary requires GLIBC_2.34', + message: + "This host's node-pty binary was built against a newer C library than the host provides, so the dynamic loader refuses it. Rebuild node-pty on this host, or deploy a build whose prebuilt binary matches this platform's libc. (the binary requires GLIBC_2.34)" + }) + }) + + it('reports browser and terminal loss together, because they fail independently', () => { + setRuntimeBrowserCommandsFactory(null) + setRuntimeBrowserUnavailableCause({ reason: 'unconfigured' }) + setRuntimeTerminalUnavailableCause({ reason: 'dependency_missing' }) + + const codes = (createRuntime().getStatus().degradations ?? []).map((entry) => entry.code) + + expect(codes).toEqual(['browser_unavailable', 'terminal_unavailable']) + }) + + it('says nothing about terminals when no precondition proved them broken', () => { + // Silence must mean "nothing proved it broken", never "proved working" — a host that + // never ran the precondition has no verdict to publish. + const degradations = createRuntime().getStatus().degradations ?? [] + expect(degradations.map((entry) => entry.code)).not.toContain('terminal_unavailable') + }) + it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => { const runtime = createRuntime() const closeTab = vi.fn().mockResolvedValue(undefined) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ee1f977ca88..0b25b3cef46 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -448,6 +448,7 @@ import type { LinearTeamStatesResult, LinearStatusSetResult } from '../../shared/linear/agent-access' +import { runtimeTerminalDegradation } from './native-terminal-availability' import { BROWSER_UNAVAILABLE_ERROR_CODE, browserUnavailableMessage, @@ -6482,6 +6483,12 @@ export class OrcaRuntimeService { } ] : [] + // Why appended rather than merged into the ternary: PTY loss and browser loss are + // independent, and a host can be degraded on both at once. + const terminalDegradation = runtimeTerminalDegradation() + if (terminalDegradation) { + degradations.push(terminalDegradation) + } return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, diff --git a/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts b/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts index a74c48b896b..8210b0f06d4 100644 --- a/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts +++ b/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts @@ -518,6 +518,109 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { } }) + it('honours a pinned bind host over exposeNetworkByDefault (orcad --bind)', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + // Why both: an unattended host passes an explicit answer, and it must outrank every + // implicit widen — otherwise "default loopback" is only true until something else wins. + exposeNetworkByDefault: true, + pinnedBindHost: '127.0.0.1' + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + expect(new URL(server.getWebSocketEndpoint()!).hostname).toBe('127.0.0.1') + } finally { + await server.stop() + } + }) + + it('stays pinned to loopback even after a device has connected once', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why this case specifically: the unpinned default widens at the NEXT startup once any + // network-reach device has connected. A loopback orcad would therefore go wide one + // restart after its first client paired — silently, and without the operator asking. + const registry = new DeviceRegistry(userDataPath) + const device = registry.getOrCreatePendingDevice('CLI', 'runtime', 'network') + registry.updateLastSeen(device.deviceId) + + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '127.0.0.1' + }) + + await server.start() + try { + expect( + server + .getDeviceRegistry() + ?.listDevices() + .some((d) => d.lastSeenAt > 0) + ).toBe(true) + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + } finally { + await server.stop() + } + }) + + it('refuses a runtime-widening pairing offer while the bind is pinned to loopback', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '127.0.0.1' + }) + + await server.start() + try { + // A paired client can reach this RPC. Without the pin's refusal it would rebind the + // listener to every interface, undoing the operator's bind policy from the outside. + const offer = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(offer.available).toBe(false) + if (!offer.available) { + expect(offer.reason).toBe('network_exposure_failed') + } + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + } finally { + await server.stop() + errorSpy.mockRestore() + } + }) + + it('still widens on request when the operator pinned the wide address', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '0.0.0.0' + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + await server.ensureNetworkExposure() + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + } finally { + await server.stop() + } + }) + it('refuses to widen a pairing offer that arrives after the server has stopped', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 1b5899f346c..b28619b2eed 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -59,6 +59,12 @@ const DEFAULT_WS_PORT = 6768 const WS_BIND_HOST_LOOPBACK = '127.0.0.1' const WS_BIND_HOST_ALL_INTERFACES = '0.0.0.0' +// Why brackets: `ws://::1:6768` is not a URL, and every consumer of this endpoint parses it +// with `new URL`. An IPv6 bind address would otherwise publish an unparseable endpoint. +function formatWsEndpoint(host: string, port: number): string { + return `ws://${host.includes(':') ? `[${host}]` : host}:${port}` +} + type OrcaRuntimeRpcServerOptions = { runtime: OrcaRuntimeService userDataPath: string @@ -71,6 +77,16 @@ type OrcaRuntimeRpcServerOptions = { // Why: STA-2370 — bind the WS listener to all interfaces at startup instead of loopback-until-paired. // Only `orca serve` (explicit remote opt-in) and E2E set this; the desktop app widens lazily on pairing. exposeNetworkByDefault?: boolean + /** + * Pin the WS listener to exactly this address for the process's whole life. + * + * Why a pin and not another default: the two paths below both widen on their own — + * `exposeNetworkByDefault` at startup, and a device that has connected once at every + * later startup. An unattended host (orcad) whose operator asked for loopback must + * still be on loopback after a client pairs and the service restarts, so the answer + * has to outrank both, and `ensureNetworkExposure()` has to refuse rather than widen. + */ + pinnedBindHost?: string webClientRoot?: string // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). keepaliveIntervalMs?: number @@ -495,6 +511,7 @@ export class OrcaRuntimeRpcServer { private readonly wsPort: number private readonly preferPinnedWsPort: boolean private readonly exposeNetworkByDefault: boolean + private readonly pinnedBindHost: string | null private readonly webClientRoot: string | undefined // Why: STA-2370 — the host the WS listener is currently bound to, so pairing can widen loopback→all-interfaces once. private wsBoundHost: string | null = null @@ -555,6 +572,7 @@ export class OrcaRuntimeRpcServer { wsPort = DEFAULT_WS_PORT, preferPinnedWsPort = false, exposeNetworkByDefault = false, + pinnedBindHost, webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP, @@ -570,6 +588,7 @@ export class OrcaRuntimeRpcServer { this.wsPort = wsPort this.preferPinnedWsPort = preferPinnedWsPort this.exposeNetworkByDefault = exposeNetworkByDefault + this.pinnedBindHost = pinnedBindHost ?? null this.webClientRoot = webClientRoot this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap @@ -1241,6 +1260,9 @@ export class OrcaRuntimeRpcServer { // A grant minted for "This computer only" is excluded: its client is a browser on this machine, so // counting it would republish the runtime on every interface one restart after the user declined that. private resolveInitialWebSocketBindHost(): string { + if (this.pinnedBindHost) { + return this.pinnedBindHost + } if (this.exposeNetworkByDefault) { return WS_BIND_HOST_ALL_INTERFACES } @@ -1286,7 +1308,7 @@ export class OrcaRuntimeRpcServer { this.wsBoundHost = options.host return { transport: wsTransport, - endpoint: `ws://${options.host}:${wsTransport.resolvedPort}` + endpoint: formatWsEndpoint(options.host, wsTransport.resolvedPort) } } @@ -1359,6 +1381,14 @@ export class OrcaRuntimeRpcServer { // connected when a later LAN/QR offer opts in. Rebinding terminates them (ws cannot move a listener), // so the resolved port is reused — already-issued endpoints stay valid and clients reconnect in place. async ensureNetworkExposure(): Promise { + if (this.pinnedBindHost && this.pinnedBindHost !== WS_BIND_HOST_ALL_INTERFACES) { + // Why throw and not return: callers widen so they can ADVERTISE a LAN endpoint. Returning + // quietly would let them publish one that nothing can reach; the throw lands in their + // existing network_exposure_failed branch, which reports the offer unavailable instead. + throw new Error( + `Runtime bind address is pinned to ${this.pinnedBindHost}; refusing to widen to all interfaces` + ) + } if ( !this.enableWebSocket || this.stopping || diff --git a/src/main/server/serve-readiness.test.ts b/src/main/server/serve-readiness.test.ts index 7e037eba025..3e4b77e695f 100644 --- a/src/main/server/serve-readiness.test.ts +++ b/src/main/server/serve-readiness.test.ts @@ -4,6 +4,7 @@ import { ServeReadinessPublisher, type ServeReadiness } from './serve-readiness' +import type { OrcadHealth } from '../orcad/orcad-health' const ready: ServeReadiness = { runtimeId: 'runtime-1', @@ -21,6 +22,25 @@ const ready: ServeReadiness = { } } +const health: OrcadHealth = { + buildHash: 'abc123def4567890', + buildVersion: '1.4.0', + nodeVersion: '20.11.0', + nodeAbi: '115', + platform: 'linux', + arch: 'x64', + pid: 1234, + terminalDaemon: { + state: 'live', + ownsFreshSessions: true, + pid: 4242, + buildVersion: '1.4.0', + entryPath: '/opt/orcad/daemon-entry.js', + protocolVersion: 36, + selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 12 } + } +} + describe('ServeReadinessPublisher', () => { it('writes one complete human-readable ready block', async () => { const write = vi.fn(async () => {}) @@ -90,6 +110,50 @@ describe('ServeReadinessPublisher', () => { ).toThrow('websocket_unavailable. Choose an unused --port.') }) + it('omits the health block entirely when a host does not report one', () => { + const payload = JSON.parse(renderServeReadiness(ready, { mode: 'json' })) + // Why absent rather than a null/empty object: a reader must be able to tell "this host + // does not publish health" from "this host published a green verdict". + expect('health' in payload).toBe(false) + expect(renderServeReadiness(ready, { mode: 'human' })).not.toContain('Terminal daemon') + }) + + it('carries build identity, Node ABI and the daemon self-test in the JSON contract', () => { + const payload = JSON.parse(renderServeReadiness({ ...ready, health }, { mode: 'json' })) + expect(payload.health.buildHash).toBe('abc123def4567890') + expect(payload.health.nodeAbi).toBe('115') + expect(payload.health.terminalDaemon.selfTest).toEqual({ + ok: true, + coverage: 'pty-spawn', + verdict: 'healthy', + durationMs: 12 + }) + }) + + it('says out loud when the daemon self-test failed', () => { + const failed: ServeReadiness = { + ...ready, + health: { + ...health, + terminalDaemon: { + ...health.terminalDaemon, + state: 'degraded', + ownsFreshSessions: false, + selfTest: { + ok: false, + coverage: 'pty-spawn', + verdict: 'pty-spawn-unhealthy', + durationMs: 3_000 + } + } + } + } + const human = renderServeReadiness(failed, { mode: 'human' }) + // An operator reading the ready block must not have to infer this from a missing line. + expect(human).toContain('PTY self-test FAILED') + expect(human).toContain('terminals survive an orcad restart: NO') + }) + it('rejects concurrent and later duplicate publications', async () => { let finishWrite: (() => void) | undefined const publisher = new ServeReadinessPublisher( diff --git a/src/main/server/serve-readiness.ts b/src/main/server/serve-readiness.ts index e4ab3db8925..b0bdf14daba 100644 --- a/src/main/server/serve-readiness.ts +++ b/src/main/server/serve-readiness.ts @@ -1,4 +1,5 @@ import type { PairingOfferUnavailableReason } from '../runtime/runtime-rpc' +import type { OrcadHealth } from '../orcad/orcad-health' export type ServePairingUnavailableReason = PairingOfferUnavailableReason | 'disabled_by_operator' @@ -24,6 +25,14 @@ export type ServeReadiness = { advertisedEndpoint: string | null managedWslCliReconciliation: 'pending' | 'settled' | 'failed' pairing: ServePairingReadiness + /** + * Build identity, Node ABI and the cross-process terminal-daemon self-test. + * + * Optional because the Electron `--serve` host does not publish one yet; readers must + * treat its absence as "not reported", never as healthy. Additive, so an older client + * parsing this payload is unaffected. + */ + health?: OrcadHealth } export type ServeReadinessOutput = @@ -77,7 +86,8 @@ export function renderServeReadiness( boundEndpoint: readiness.boundEndpoint, advertisedEndpoint: readiness.advertisedEndpoint, managedWslCliReconciliation: readiness.managedWslCliReconciliation, - pairing: readiness.pairing + pairing: readiness.pairing, + ...(readiness.health ? { health: readiness.health } : {}) }) } return renderHumanReadiness(readiness) @@ -89,6 +99,18 @@ function renderHumanReadiness(readiness: ServeReadiness): string { `Bound endpoint: ${readiness.boundEndpoint ?? 'websocket unavailable'}`, `Advertised endpoint: ${readiness.advertisedEndpoint ?? 'unavailable'}` ] + if (readiness.health) { + const daemon = readiness.health.terminalDaemon + lines.push( + `Build: ${readiness.health.buildVersion} (${readiness.health.buildHash}), Node ` + + `${readiness.health.nodeVersion} ABI ${readiness.health.nodeAbi}` + ) + lines.push( + `Terminal daemon: ${daemon.state} — PTY self-test ${daemon.selfTest.ok ? 'passed' : 'FAILED'}` + + ` (${daemon.selfTest.coverage}: ${daemon.selfTest.verdict})` + + `; terminals survive an orcad restart: ${daemon.ownsFreshSessions ? 'yes' : 'NO'}` + ) + } if (readiness.pairing.available) { if (readiness.pairing.webClientUrl) { lines.push(`Web client URL: ${readiness.pairing.webClientUrl}`) diff --git a/src/main/ssh/build-toolchain-diagnosis.ts b/src/main/ssh/build-toolchain-diagnosis.ts new file mode 100644 index 00000000000..446601518c9 --- /dev/null +++ b/src/main/ssh/build-toolchain-diagnosis.ts @@ -0,0 +1,166 @@ +/** + * Diagnose a host's C/C++ build toolchain from a POSIX-sh probe. + * + * Why this is separate from `ssh-relay-build-toolchain.ts`: everything here is pure — + * it builds a shell command, parses its output, and formats messages. The relay reaches + * it over SSH; `orcad` reaches it over a local shell. Keeping it transport-free is what + * lets the Node-only bundle reuse it without pulling `ssh2` in behind it. + */ +// Why: node-pty@1.1.0 ships no Linux prebuild, so the remote `npm install` falls +// back to `node-gyp rebuild` and needs a C/C++ toolchain. A missing toolchain is +// the dominant first-connect failure on Linux relays (#1693); node-gyp surfaces +// it as an opaque `not found: make`. We probe for the tools so we can replace +// that with an actionable "install build-essential" message. +const PROBED_TOOLS = [ + 'make', + 'gcc', + 'g++', + 'cc', + 'c++', + 'clang', + 'clang++', + 'python3', + 'python' +] as const + +// Package managers mapped to the one-liner that installs a C/C++ toolchain on +// the matching distro family. Ordered by detection priority. +const PACKAGE_MANAGER_HINTS: readonly { bin: string; install: string }[] = [ + { bin: 'apt-get', install: 'sudo apt-get install -y build-essential python3' }, + { bin: 'dnf', install: 'sudo dnf install -y make gcc gcc-c++ python3' }, + { bin: 'yum', install: 'sudo yum install -y make gcc gcc-c++ python3' }, + { bin: 'pacman', install: 'sudo pacman -S --needed base-devel python' }, + { bin: 'apk', install: 'sudo apk add build-base python3' }, + { bin: 'zypper', install: 'sudo zypper install -y gcc gcc-c++ make python3' } +] + +export type BuildToolchainStatus = { + present: string[] + packageManager: string | null + // node-gyp needs make, Python, and a C++ compiler. The caller only uses this + // verdict after npm/node-gyp output already points at a native-build failure, + // so custom Python paths do not make unrelated npm failures look toolchainy. + toolchainMissing: boolean +} + +function hasCxxCompiler(present: ReadonlySet): boolean { + return present.has('g++') || present.has('c++') || present.has('clang++') +} + +function hasPython(present: ReadonlySet): boolean { + return present.has('python3') || present.has('python') +} + +// POSIX-sh probe: echo a `HAVE ` line per resolvable build tool and a +// single `PKG ` line for the host's package manager. Runs under +// `/bin/sh -c` (see wrapRemoteCommandForPosixShell), so it stays portable. +export function buildToolchainProbeCommand(): string { + const toolLoop = `for t in ${PROBED_TOOLS.join( + ' ' + )}; do if command -v "$t" >/dev/null 2>&1; then echo "HAVE $t"; fi; done` + const pkgList = PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ') + const pkgLoop = `for p in ${pkgList}; do if command -v "$p" >/dev/null 2>&1; then echo "PKG $p"; break; fi; done` + return `${toolLoop}; ${pkgLoop}` +} + +export function parseBuildToolchainProbe(output: string): BuildToolchainStatus { + const present = new Set() + let packageManager: string | null = null + for (const line of output.split('\n')) { + const haveMatch = line.trim().match(/^HAVE (\S+)$/) + if (haveMatch) { + present.add(haveMatch[1]) + continue + } + const pkgMatch = line.trim().match(/^PKG (\S+)$/) + if (pkgMatch && !packageManager) { + packageManager = pkgMatch[1] + } + } + return { + present: PROBED_TOOLS.filter((tool) => present.has(tool)), + packageManager, + toolchainMissing: !present.has('make') || !hasCxxCompiler(present) || !hasPython(present) + } +} + +export function shouldProbeBuildToolchainAfterNativeDepsFailure(message: string): boolean { + const lower = message.toLowerCase() + if (!lower.includes('gyp') && !lower.includes('node-gyp')) { + return false + } + return ( + /\bnot found:\s*(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b/i.test( + message + ) || + /\b(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b.*\bnot found\b/i.test( + message + ) || + lower.includes('could not find any python installation') || + lower.includes('no xcode or clt version detected') + ) +} + +function missingToolNames(status: BuildToolchainStatus): string[] { + const present = new Set(status.present) + const missing: string[] = [] + if (!present.has('make')) { + missing.push('make') + } + if (!hasCxxCompiler(present)) { + missing.push('a C++ compiler (g++ or clang++)') + } + if (!hasPython(present)) { + missing.push('python3') + } + return missing +} + +/** Install hint for the host's package manager, or the cross-distro list when it is unknown. */ +export function toolchainInstallHintLines(status: BuildToolchainStatus): string[] { + const tailored = status.packageManager + ? PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === status.packageManager)?.install + : null + if (tailored) { + return [` ${tailored}`] + } + return [ + ' Debian/Ubuntu: sudo apt-get install -y build-essential python3', + ' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3', + ' Arch: sudo pacman -S --needed base-devel python', + ' Alpine: sudo apk add build-base python3' + ] +} + +/** One-line summary for the deploy log when node-pty is skipped rather than compiled. */ +export function formatSkippedNodePtyWarning(status: BuildToolchainStatus): string { + // Why: with no package manager detected the hint list is the cross-distro menu, whose first line + // is Debian's — quoting it alone would name the wrong distro, so stay neutral instead. + const hintLines = toolchainInstallHintLines(status) + const hint = + hintLines.length === 1 + ? hintLines[0].trim() + : 'install a C/C++ toolchain (make, a C++ compiler, python3)' + return ( + `missing build tools (${missingToolNames(status).join(', ')}); skipping node-pty so the ` + + `connection still serves files and git. Remote terminals need: ${hint}` + ) +} + +export function formatMissingToolchainError( + status: BuildToolchainStatus, + underlyingError: string +): string { + const lines = [ + `The remote host is missing the C/C++ build tools (${missingToolNames(status).join(', ')}) ` + + `needed to compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` + + `prebuilt binary for Linux, so they must be compiled on the remote host.`, + '', + 'Install the build tools on the remote host, then reconnect:', + ...toolchainInstallHintLines(status), + '', + `Underlying install error: ${underlyingError}` + ] + return lines.join('\n') +} + diff --git a/src/main/ssh/orcad-activation-gate.test.ts b/src/main/ssh/orcad-activation-gate.test.ts new file mode 100644 index 00000000000..534d41c08f0 --- /dev/null +++ b/src/main/ssh/orcad-activation-gate.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' + +import { evaluateOrcadActivation } from './orcad-activation-gate' +import type { ServeReadiness } from '../server/serve-readiness' +import type { OrcadHealth, TerminalDaemonHealth } from '../orcad/orcad-health' + +const EXPECTED = { buildHash: 'abc123def4567890', fullVersion: '0.2.0+bb01' } + +function daemon(overrides: Partial = {}): TerminalDaemonHealth { + return { + state: 'live', + ownsFreshSessions: true, + pid: 4242, + buildVersion: '0.2.0+bb01', + entryPath: '/home/u/.orca-remote/orcad-0.2.0+bb01/daemon-entry.js', + protocolVersion: 3, + selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 12 }, + ...overrides + } +} + +function health(overrides: Partial = {}): OrcadHealth { + return { + buildHash: EXPECTED.buildHash, + buildVersion: EXPECTED.fullVersion, + nodeVersion: '20.11.0', + nodeAbi: '115', + platform: 'linux', + arch: 'x64', + pid: 4200, + terminalDaemon: daemon(), + ...overrides + } +} + +function readiness(overrides: Partial = {}): ServeReadiness { + return { + runtimeId: 'runtime-1', + boundEndpoint: 'ws://127.0.0.1:7777', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled', + pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, + health: health(), + ...overrides + } +} + +describe('evaluateOrcadActivation', () => { + it('activates a candidate that proved a real PTY round trip', () => { + const verdict = evaluateOrcadActivation(readiness(), EXPECTED) + expect(verdict).toEqual({ decision: 'activate', coverage: 'pty-spawn', warnings: [] }) + }) + + it('refuses when the candidate never published readiness', () => { + const verdict = evaluateOrcadActivation(null, EXPECTED) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_no_readiness' }) + }) + + it('refuses a readiness payload with no health, rather than reading silence as healthy', () => { + const { health: _dropped, ...withoutHealth } = readiness() + const verdict = evaluateOrcadActivation(withoutHealth as ServeReadiness, EXPECTED) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_no_health' }) + }) + + it('refuses when a different build answered — a stale process holding the port', () => { + const verdict = evaluateOrcadActivation( + readiness({ health: health({ buildHash: '0000000000000000' }) }), + EXPECTED + ) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_build_mismatch' }) + }) + + it('refuses a listening orcad whose terminal daemon is absent', () => { + const verdict = evaluateOrcadActivation( + readiness({ + health: health({ + terminalDaemon: daemon({ + state: 'absent', + ownsFreshSessions: false, + selfTest: { ok: false, coverage: 'pty-spawn', verdict: 'no-daemon', durationMs: 1 } + }) + }) + }), + EXPECTED + ) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_daemon_absent' }) + }) + + it('refuses a degraded daemon, whose fresh terminals would not survive a restart', () => { + const verdict = evaluateOrcadActivation( + readiness({ + health: health({ terminalDaemon: daemon({ state: 'degraded', ownsFreshSessions: false }) }) + }), + EXPECTED + ) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_daemon_degraded' }) + }) + + it('refuses a live daemon that failed its PTY spawn probe', () => { + const verdict = evaluateOrcadActivation( + readiness({ + health: health({ + terminalDaemon: daemon({ + selfTest: { + ok: false, + coverage: 'pty-spawn', + verdict: 'pty-spawn-unhealthy', + durationMs: 30 + } + }) + }) + }), + EXPECTED + ) + expect(verdict).toMatchObject({ + decision: 'reject', + code: 'orcad_activation_pty_self_test_failed' + }) + }) + + it('refuses a green daemon that does not own fresh sessions', () => { + const verdict = evaluateOrcadActivation( + readiness({ health: health({ terminalDaemon: daemon({ ownsFreshSessions: false }) }) }), + EXPECTED + ) + expect(verdict).toMatchObject({ + decision: 'reject', + code: 'orcad_activation_no_persistent_terminals' + }) + }) + + it('refuses a candidate that is not listening', () => { + const verdict = evaluateOrcadActivation(readiness({ boundEndpoint: null }), EXPECTED) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_not_listening' }) + }) + + it('activates handshake-only coverage but never records it as a proven PTY', () => { + const verdict = evaluateOrcadActivation( + readiness({ + health: health({ + platform: 'win32', + terminalDaemon: daemon({ + selfTest: { ok: true, coverage: 'handshake', verdict: 'healthy', durationMs: 5 } + }) + }) + }), + EXPECTED + ) + expect(verdict).toMatchObject({ decision: 'activate', coverage: 'handshake' }) + expect(verdict.decision === 'activate' && verdict.warnings[0]).toContain( + 'covered the daemon handshake only' + ) + }) + + it('checks identity before health, so a wrong-build green payload cannot pass', () => { + const verdict = evaluateOrcadActivation( + readiness({ + health: health({ buildHash: 'ffffffffffffffff', terminalDaemon: daemon() }) + }), + EXPECTED + ) + expect(verdict).toMatchObject({ code: 'orcad_activation_build_mismatch' }) + }) +}) diff --git a/src/main/ssh/orcad-activation-gate.ts b/src/main/ssh/orcad-activation-gate.ts new file mode 100644 index 00000000000..c9581919117 --- /dev/null +++ b/src/main/ssh/orcad-activation-gate.ts @@ -0,0 +1,155 @@ +/** + * Whether a freshly launched orcad has earned the right to become the active one. + * + * The failure this exists to prevent is the one `docs/design/shipping-orcad.html` names + * throughout: a deployment that reports success because a port opened. orcad answers RPC + * from its own process, so "listening" stays true while the terminal daemon that owns every + * terminal is dead — a green host that cannot run a single command. Activation therefore + * reads the cross-process health payload the candidate published, not the exit code of the + * command that started it. + * + * A refusal here is not a failure to deploy. The bytes are installed and the previous + * version is still active; nothing was lost. Activating on a bad verdict is what loses + * things. + */ +import type { ServeReadiness } from '../server/serve-readiness' + +export type OrcadActivationRejectCode = + | 'orcad_activation_no_readiness' + | 'orcad_activation_no_health' + | 'orcad_activation_build_mismatch' + | 'orcad_activation_not_listening' + | 'orcad_activation_daemon_absent' + | 'orcad_activation_daemon_degraded' + | 'orcad_activation_pty_self_test_failed' + | 'orcad_activation_no_persistent_terminals' + +export type OrcadActivationVerdict = + | { + decision: 'activate' + /** + * `pty-spawn` means a real PTY was created and torn down inside the daemon. + * `handshake` means the daemon answered but its spawn probe is a no-op on this + * platform (win32). Carried through so an activation is never recorded as proving + * more than it did. + */ + coverage: 'pty-spawn' | 'handshake' + warnings: string[] + } + | { decision: 'reject'; code: OrcadActivationRejectCode; reason: string } + +export type OrcadActivationExpectation = { + /** sha256(orcad.js).slice(0,16) computed from the bytes this client just uploaded. */ + buildHash: string + /** The full content-hashed version this deploy installed. */ + fullVersion: string +} + +/** + * Gate an activation on what the candidate actually reported. + * + * Order matters: identity before health. A health payload from the wrong process is worse + * than no payload, because it is green and about something else. + */ +export function evaluateOrcadActivation( + readiness: ServeReadiness | null, + expected: OrcadActivationExpectation +): OrcadActivationVerdict { + if (!readiness) { + return { + decision: 'reject', + code: 'orcad_activation_no_readiness', + reason: + 'The candidate orcad never published an `orca_server_ready` line. It may have exited, ' + + 'failed to bind, or be wedged before readiness. Nothing was activated.' + } + } + const health = readiness.health + if (!health) { + return { + decision: 'reject', + code: 'orcad_activation_no_health', + reason: + 'The candidate published readiness without a health payload, so its terminal daemon ' + + 'is unverified. Absence of a verdict is not a healthy verdict — treat this build as ' + + 'too old to gate on and do not activate it.' + } + } + // Why identity first: a stale orcad already holding the port would answer readiness and + // report its own (healthy) daemon. Activating on that record points the pointer at bytes + // nobody is running. + if (health.buildHash !== expected.buildHash) { + return { + decision: 'reject', + code: 'orcad_activation_build_mismatch', + reason: + `The process that answered is running build ${health.buildHash}, not the ` + + `${expected.buildHash} this deploy installed. Something else owns that port, or the ` + + 'upload did not land. Nothing was activated.' + } + } + if (!readiness.boundEndpoint) { + return { + decision: 'reject', + code: 'orcad_activation_not_listening', + reason: + 'The candidate reported no bound endpoint, so no client could reach it. Nothing was ' + + 'activated.' + } + } + const daemon = health.terminalDaemon + if (daemon.state === 'absent') { + return { + decision: 'reject', + code: 'orcad_activation_daemon_absent', + reason: + 'The candidate has no terminal daemon. Every terminal on this host would run in the ' + + 'orcad process and die with it, which is the exact regression the daemon exists to ' + + 'prevent. Nothing was activated.' + } + } + if (daemon.state === 'degraded') { + return { + decision: 'reject', + code: 'orcad_activation_daemon_degraded', + reason: + `The candidate's terminal daemon is degraded (self-test: ${daemon.selfTest.verdict}). ` + + 'Existing sessions keep working, but fresh terminals would not survive a restart. ' + + 'Nothing was activated; the previous version is still serving.' + } + } + if (!daemon.selfTest.ok) { + return { + decision: 'reject', + code: 'orcad_activation_pty_self_test_failed', + reason: + `The candidate's PTY self-test failed (${daemon.selfTest.verdict}). The host is ` + + 'listening but cannot create a terminal. Nothing was activated.' + } + } + if (!daemon.ownsFreshSessions) { + return { + decision: 'reject', + code: 'orcad_activation_no_persistent_terminals', + reason: + 'The candidate answered healthy but does not own fresh sessions, so new terminals ' + + 'would not survive its own restart. Nothing was activated.' + } + } + const warnings: string[] = [] + if (daemon.selfTest.coverage === 'handshake') { + warnings.push( + 'The PTY self-test covered the daemon handshake only — this platform does not spawn a ' + + 'probe PTY. Terminal creation is unproven on this host.' + ) + } + if (health.buildVersion !== expected.fullVersion) { + // Not a rejection: the hash already proved identity, and ORCA_VERSION is whatever the + // launch command exported. Worth saying, because a mismatch means the launch env is wrong. + warnings.push( + `The candidate reports version ${health.buildVersion} but was installed as ` + + `${expected.fullVersion}; check ORCA_VERSION in the launch command.` + ) + } + return { decision: 'activate', coverage: daemon.selfTest.coverage, warnings } +} diff --git a/src/main/ssh/orcad-activation-record-store.ts b/src/main/ssh/orcad-activation-record-store.ts new file mode 100644 index 00000000000..d60634ec525 --- /dev/null +++ b/src/main/ssh/orcad-activation-record-store.ts @@ -0,0 +1,48 @@ +/** + * Reading the activation record off a host. + * + * Split out from the deploy driver because the rollback path needs it too, and because an + * unreadable record must fail loudly in both: treating "I cannot parse this" as "nothing is + * activated" would deploy over a live install and lose its rollback target. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_ACTIVATION_FILENAME, + emptyOrcadActivationRecord, + parseOrcadActivationRecord, + type OrcadActivationRecord +} from './orcad-activation-record' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +export function orcadActivationPath(host: RemoteHostPlatform, remoteHome: string): string { + return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, ORCAD_ACTIVATION_FILENAME) +} + +export async function readOrcadActivationRecord(options: { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + signal?: AbortSignal +}): Promise { + const path = orcadActivationPath(options.host, options.remoteHome) + const raw = await execCommand(options.conn, `cat ${shellQuote(path)} 2>/dev/null || true`, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }).catch(() => '') + const parsed = parseOrcadActivationRecord(raw) + if (parsed.state === 'ok') { + return parsed.record + } + if (parsed.state === 'unreadable') { + // Why throw: an unreadable record is not an empty one. Treating it as empty would + // activate over a live install and orphan its rollback target. + throw new Error(`Cannot read this host's orcad activation record: ${parsed.reason}`) + } + return emptyOrcadActivationRecord() +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} diff --git a/src/main/ssh/orcad-activation-record.test.ts b/src/main/ssh/orcad-activation-record.test.ts new file mode 100644 index 00000000000..dd625238bb1 --- /dev/null +++ b/src/main/ssh/orcad-activation-record.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' + +import { + emptyOrcadActivationRecord, + orcadGcPinnedDirNames, + parseOrcadActivationRecord, + serializeOrcadActivationRecord, + withActivatedVersion, + withRolledBackVersion, + type OrcadStateSnapshot +} from './orcad-activation-record' + +const SNAPSHOT: OrcadStateSnapshot = { + dirName: 'pre-0.2.0+bb01-1000', + takenBeforeVersion: '0.2.0+bb01', + readableByVersion: '0.1.0+aa01', + takenAt: '2026-01-01T00:00:00.000Z' +} +const NOW = new Date('2026-01-02T00:00:00.000Z') + +describe('orcad activation record', () => { + it('round-trips through the host', () => { + const record = withActivatedVersion( + { ...emptyOrcadActivationRecord(), active: '0.1.0+aa01' }, + '0.2.0+bb01', + SNAPSHOT, + NOW + ) + const parsed = parseOrcadActivationRecord(serializeOrcadActivationRecord(record)) + expect(parsed).toEqual({ state: 'ok', record }) + }) + + it('reports an absent record as absent', () => { + expect(parseOrcadActivationRecord(null)).toEqual({ state: 'absent' }) + expect(parseOrcadActivationRecord(' ')).toEqual({ state: 'absent' }) + }) + + it('reports a newer schema as unreadable, never as absent', () => { + const parsed = parseOrcadActivationRecord(JSON.stringify({ schemaVersion: 2, active: 'x' })) + expect(parsed.state).toBe('unreadable') + }) + + it('reports corrupt JSON as unreadable, never as absent', () => { + expect(parseOrcadActivationRecord('{not json').state).toBe('unreadable') + }) + + it('names the outgoing version as the rollback target', () => { + const record = withActivatedVersion( + { ...emptyOrcadActivationRecord(), active: '0.1.0+aa01' }, + '0.2.0+bb01', + SNAPSHOT, + NOW + ) + expect(record).toMatchObject({ active: '0.2.0+bb01', previous: '0.1.0+aa01' }) + }) + + it('does not let a re-deploy of the active version erase the rollback target', () => { + const before = { + ...emptyOrcadActivationRecord(), + active: '0.2.0+bb01', + previous: '0.1.0+aa01', + snapshot: SNAPSHOT + } + const after = withActivatedVersion(before, '0.2.0+bb01', null, NOW) + expect(after).toMatchObject({ active: '0.2.0+bb01', previous: '0.1.0+aa01' }) + expect(after.snapshot).toEqual(SNAPSHOT) + }) + + it('clears the rollback target after rolling back, so it cannot walk into the bad build', () => { + const before = { + ...emptyOrcadActivationRecord(), + active: '0.2.0+bb01', + previous: '0.1.0+aa01', + snapshot: SNAPSHOT + } + expect(withRolledBackVersion(before, NOW)).toMatchObject({ + active: '0.1.0+aa01', + previous: null, + snapshot: null + }) + }) + + it('pins the active version, the rollback target and the live daemon"s bundle against GC', () => { + const pinned = orcadGcPinnedDirNames( + { + ...emptyOrcadActivationRecord(), + active: '0.3.0+cc01', + previous: '0.2.0+bb01' + }, + '0.1.0+aa01' + ) + expect(pinned).toEqual(['orcad-0.3.0+cc01', 'orcad-0.2.0+bb01', 'orcad-0.1.0+aa01']) + }) + + it('deduplicates pins when the live daemon came from the active bundle', () => { + const pinned = orcadGcPinnedDirNames( + { ...emptyOrcadActivationRecord(), active: '0.3.0+cc01', previous: null }, + '0.3.0+cc01' + ) + expect(pinned).toEqual(['orcad-0.3.0+cc01']) + }) +}) diff --git a/src/main/ssh/orcad-activation-record.ts b/src/main/ssh/orcad-activation-record.ts new file mode 100644 index 00000000000..8c32e6c355d --- /dev/null +++ b/src/main/ssh/orcad-activation-record.ts @@ -0,0 +1,175 @@ +/** + * Which installed orcad is the live one, and which one a rollback goes back to. + * + * A versioned install directory decides where bytes land; it does not decide which version + * runs. Without this record, "roll back" means "deploy the old version again" — which needs + * the client that has those bytes, on a host that may be the only thing still working. The + * record is the host-side half: it names an active version, a rollback target, and the + * pre-activation state snapshot that makes going back to that target sound. + * + * It lives beside the version dirs (`~/.orca-remote/orcad-active.json`), not inside one, + * because it has to outlive whichever version GC removes. + */ +import { remoteInstallDirName, ORCAD_INSTALL_MODEL } from './remote-install-model' + +export const ORCAD_ACTIVATION_FILENAME = 'orcad-active.json' +export const ORCAD_ACTIVATION_SCHEMA_VERSION = 1 + +/** Where a pre-activation copy of the shared data root lives, relative to `.orca-remote/`. */ +export const ORCAD_STATE_SNAPSHOT_DIR = 'orcad-state-snapshots' + +export type OrcadStateSnapshot = { + /** Directory name under `ORCAD_STATE_SNAPSHOT_DIR`. */ + dirName: string + /** The version whose activation this snapshot was taken FOR — i.e. taken before it ran. */ + takenBeforeVersion: string + /** The version that produced the state, i.e. the rollback target it is readable by. */ + readableByVersion: string | null + takenAt: string +} + +export type OrcadActivationRecord = { + schemaVersion: typeof ORCAD_ACTIVATION_SCHEMA_VERSION + /** Full content-hashed version, e.g. `0.1.0+9f2a1c`. Null before the first activation. */ + active: string | null + /** The version `active` replaced. The rollback target, and pinned against GC. */ + previous: string | null + activatedAt: string | null + snapshot: OrcadStateSnapshot | null +} + +export function emptyOrcadActivationRecord(): OrcadActivationRecord { + return { + schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, + active: null, + previous: null, + activatedAt: null, + snapshot: null + } +} + +/** + * Parse the record read off the host. + * + * Why a null return and not a throw on a newer schema: a client older than the host must not + * treat "I cannot read this" as "nothing is activated" — that would deploy over a live + * install. Callers distinguish the two through `OrcadActivationReadResult`. + */ +export type OrcadActivationReadResult = + | { state: 'absent' } + | { state: 'ok'; record: OrcadActivationRecord } + | { state: 'unreadable'; reason: string } + +export function parseOrcadActivationRecord(raw: string | null): OrcadActivationReadResult { + if (raw === null || raw.trim() === '') { + return { state: 'absent' } + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (error) { + return { + state: 'unreadable', + reason: `activation record is not JSON: ${error instanceof Error ? error.message : String(error)}` + } + } + if (typeof parsed !== 'object' || parsed === null) { + return { state: 'unreadable', reason: 'activation record is not an object' } + } + const record = parsed as Partial + if (record.schemaVersion !== ORCAD_ACTIVATION_SCHEMA_VERSION) { + return { + state: 'unreadable', + reason: + `activation record schemaVersion ${String(record.schemaVersion)} is not ` + + `${ORCAD_ACTIVATION_SCHEMA_VERSION}; this client cannot safely interpret it` + } + } + return { + state: 'ok', + record: { + schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, + active: typeof record.active === 'string' ? record.active : null, + previous: typeof record.previous === 'string' ? record.previous : null, + activatedAt: typeof record.activatedAt === 'string' ? record.activatedAt : null, + snapshot: parseSnapshot(record.snapshot) + } + } +} + +function parseSnapshot(value: unknown): OrcadStateSnapshot | null { + if (typeof value !== 'object' || value === null) { + return null + } + const snapshot = value as Partial + if (typeof snapshot.dirName !== 'string' || typeof snapshot.takenBeforeVersion !== 'string') { + return null + } + return { + dirName: snapshot.dirName, + takenBeforeVersion: snapshot.takenBeforeVersion, + readableByVersion: + typeof snapshot.readableByVersion === 'string' ? snapshot.readableByVersion : null, + takenAt: typeof snapshot.takenAt === 'string' ? snapshot.takenAt : '' + } +} + +export function serializeOrcadActivationRecord(record: OrcadActivationRecord): string { + return `${JSON.stringify(record, null, 2)}\n` +} + +/** The record that results from activating `version`, keeping the outgoing one as the target. */ +export function withActivatedVersion( + record: OrcadActivationRecord, + version: string, + snapshot: OrcadStateSnapshot | null, + now: Date +): OrcadActivationRecord { + return { + schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, + active: version, + // Why keep the OLD previous when re-activating the same version: a repeated deploy of + // an already-active build is not a version change, so it must not erase the rollback + // target by naming the active version as its own predecessor. + previous: record.active === version ? record.previous : record.active, + activatedAt: now.toISOString(), + snapshot: record.active === version ? record.snapshot : snapshot + } +} + +/** The record that results from rolling `active` back to `previous`. */ +export function withRolledBackVersion( + record: OrcadActivationRecord, + now: Date +): OrcadActivationRecord { + return { + schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, + active: record.previous, + // Why null and not the version we just left: it is the build we are rolling back FROM, + // so offering it as the next rollback target would walk straight back into the failure. + previous: null, + activatedAt: now.toISOString(), + // The snapshot was taken before `active` ran; once restored it has been consumed. + snapshot: null + } +} + +/** + * Version dirs GC must not remove, as directory names. + * + * `previous` is here because a rollback target that GC deleted is not a rollback target. + * `daemonEntryVersion` is here because an update preserves a live daemon forked from the + * OUTGOING bundle (see orcad-update-plan.ts) — deleting the tree under a running process is + * how a later respawn finds no entry point. + */ +export function orcadGcPinnedDirNames( + record: OrcadActivationRecord, + daemonEntryVersion?: string | null +): string[] { + const versions = [record.active, record.previous, daemonEntryVersion ?? null].filter( + (v): v is string => typeof v === 'string' && v.length > 0 + ) + return [...new Set(versions)].map((version) => + remoteInstallDirName(ORCAD_INSTALL_MODEL, version) + ) +} diff --git a/src/main/ssh/orcad-local-build-hash.ts b/src/main/ssh/orcad-local-build-hash.ts new file mode 100644 index 00000000000..3740ea6d55c --- /dev/null +++ b/src/main/ssh/orcad-local-build-hash.ts @@ -0,0 +1,22 @@ +/** + * The build identity the deploy expects the host to answer with. + * + * It must be computed the same way `computeOrcadBuildHash` computes it on the host — + * sha256 of `orcad.js`, first 16 hex characters — or the activation gate would reject every + * healthy candidate. Keeping the two in one comment is deliberate: they are one contract + * split across a network, and the version string cannot stand in for it, because + * `ORCA_VERSION` is whatever the launch command exported and two builds can carry one value. + */ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +export const ORCAD_BUILD_HASH_LENGTH = 16 + +export function computeLocalOrcadBuildHash(localOrcadDir: string): string { + const entry = join(localOrcadDir, 'orcad.js') + return createHash('sha256') + .update(readFileSync(entry)) + .digest('hex') + .slice(0, ORCAD_BUILD_HASH_LENGTH) +} diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts new file mode 100644 index 00000000000..799193f3384 --- /dev/null +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + isUnconfirmedSshCommandTermination: () => false +})) +vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) +vi.mock('./ssh-relay-install-transfers', () => ({ + uploadRelayDirectory: vi.fn().mockResolvedValue(undefined), + writeRelayFile: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./orcad-local-build-hash', () => ({ + computeLocalOrcadBuildHash: () => 'abc123def4567890' +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' +import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy' +import { emptyOrcadActivationRecord, withActivatedVersion } from './orcad-activation-record' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import type { SshConnection } from './ssh-connection' + +const mockExec = vi.mocked(execCommand) +const NEW_VERSION = '0.2.0+bb01' +const OLD_VERSION = '0.1.0+aa01' + +vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ + ...(await importOriginal>()), + readLocalFullVersion: () => '0.2.0+bb01', + isRemoteInstallComplete: vi.fn().mockResolvedValue(false), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined) +})) + +function readyLine(overrides: { + buildHash?: string + daemonState?: 'live' | 'degraded' | 'absent' + selfTestOk?: boolean +}): string { + return JSON.stringify({ + type: 'orca_server_ready', + schemaVersion: 1, + runtimeId: 'r1', + boundEndpoint: 'ws://127.0.0.1:7777', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled', + pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, + health: { + buildHash: overrides.buildHash ?? 'abc123def4567890', + buildVersion: NEW_VERSION, + nodeVersion: '20.11.0', + nodeAbi: '115', + platform: 'linux', + arch: 'x64', + pid: 1, + terminalDaemon: { + state: overrides.daemonState ?? 'live', + ownsFreshSessions: (overrides.daemonState ?? 'live') === 'live', + pid: 2, + buildVersion: NEW_VERSION, + entryPath: '/x/daemon-entry.js', + protocolVersion: 3, + selfTest: { + ok: overrides.selfTestOk ?? true, + coverage: 'pty-spawn', + verdict: (overrides.selfTestOk ?? true) ? 'healthy' : 'pty-spawn-unhealthy', + durationMs: 5 + } + } + } + }) +} + +type HostScript = { + activationRecord: string + /** Readiness content per version dir, keyed by the version in the path. */ + readiness: Record + log: string[] +} + +function scriptHost(script: HostScript): void { + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + if (text.startsWith('cat ') && text.includes('orcad-active.json')) { + return script.activationRecord + } + if (text.includes('.orcad-readiness') && text.startsWith('cat ')) { + const version = Object.keys(script.readiness).find((v) => text.includes(v)) + return version ? script.readiness[version] : '' + } + if (text.includes('nohup')) { + script.log.push(`launch:${text.includes(NEW_VERSION) ? NEW_VERSION : OLD_VERSION}`) + return '9999' + } + if (text.includes('kill -TERM')) { + script.log.push(`stop:${text.includes(NEW_VERSION) ? NEW_VERSION : OLD_VERSION}`) + return 'STOPPED' + } + if (text.includes('tar -C') && text.includes('-cf')) { + script.log.push('snapshot') + return 'CAPTURED' + } + return '' + }) +} + +function options(overrides: Partial = {}): OrcadDeployOptions { + return { + conn: {} as SshConnection, + host: getRemoteHostPlatform('linux-x64'), + remoteHome: '/home/u', + localOrcadDir: '/local/out/orcad', + nodePath: '/usr/bin/node', + userDataDir: '/home/u/.orca', + bindHost: '127.0.0.1', + port: 7777, + census: { liveSessions: 0, startedSinceActivation: 0 }, + readinessTimeoutMs: 50, + sleep: async () => {}, + now: () => new Date('2026-02-02T00:00:00.000Z'), + ...overrides + } +} + +const ACTIVE_OLD = JSON.stringify( + withActivatedVersion(emptyOrcadActivationRecord(), OLD_VERSION, null, new Date(0)) +) + +describe('deployOrcad', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('installs under the orcad namespace, not the relay one', async () => { + const script: HostScript = { + activationRecord: '', + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + await deployOrcad(options()) + expect(vi.mocked(acquireInstallLock).mock.calls[0][1]).toBe( + `/home/u/.orca-remote/orcad-${NEW_VERSION}` + ) + expect(vi.mocked(uploadRelayDirectory).mock.calls[0][2]).toContain(`orcad-${NEW_VERSION}`) + }) + + it('activates a healthy candidate and records the outgoing version as the rollback target', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + const result = await deployOrcad(options()) + expect(result).toMatchObject({ outcome: 'installed-and-activated', fullVersion: NEW_VERSION }) + const written = vi + .mocked(writeRelayFile) + .mock.calls.find((call) => String(call[2]).endsWith('orcad-active.json')) + expect(JSON.parse(String(written?.[3]))).toMatchObject({ + active: NEW_VERSION, + previous: OLD_VERSION + }) + }) + + it('snapshots the shared data root before the candidate ever runs', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + await deployOrcad(options()) + expect(script.log.indexOf('snapshot')).toBeGreaterThan(-1) + expect(script.log.indexOf('snapshot')).toBeLessThan(script.log.indexOf(`launch:${NEW_VERSION}`)) + }) + + it('installs but does not activate when terminals are running', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + const result = await deployOrcad( + options({ census: { liveSessions: 2, startedSinceActivation: 0 } }) + ) + expect(result).toMatchObject({ + outcome: 'installed-not-activated', + code: 'orcad_update_terminals_running' + }) + // The bytes landed; nothing was stopped, launched or snapshotted. + expect(vi.mocked(uploadRelayDirectory)).toHaveBeenCalled() + expect(script.log).toEqual([]) + }) + + it('does not write the activation record when the candidate fails its health gate', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({ daemonState: 'degraded' }) }, + log: [] + } + scriptHost(script) + const result = await deployOrcad(options()) + expect(result).toMatchObject({ code: 'orcad_activation_daemon_degraded' }) + expect( + vi.mocked(writeRelayFile).mock.calls.some((call) => String(call[2]).endsWith('orcad-active.json')) + ).toBe(false) + }) + + it('puts the previous version back after a rejected candidate, rather than leaving the host down', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { + [NEW_VERSION]: readyLine({ selfTestOk: false }), + [OLD_VERSION]: readyLine({}) + }, + log: [] + } + scriptHost(script) + const result = await deployOrcad(options()) + expect(result).toMatchObject({ outcome: 'installed-not-activated' }) + expect(script.log).toEqual([ + 'snapshot', + `stop:${OLD_VERSION}`, + `launch:${NEW_VERSION}`, + `stop:${NEW_VERSION}`, + `launch:${OLD_VERSION}` + ]) + expect(result.outcome === 'installed-not-activated' && result.reason).toContain( + `orcad ${OLD_VERSION} was restarted and is serving again` + ) + }) + + it('refuses to activate when a different build answered the port', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { + [NEW_VERSION]: readyLine({ buildHash: 'deadbeefdeadbeef' }), + [OLD_VERSION]: readyLine({}) + }, + log: [] + } + scriptHost(script) + const result = await deployOrcad(options()) + expect(result).toMatchObject({ code: 'orcad_activation_build_mismatch' }) + }) + + it('refuses to treat an unreadable activation record as an empty one', async () => { + const script: HostScript = { + activationRecord: JSON.stringify({ schemaVersion: 99, active: 'x' }), + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + await expect(deployOrcad(options())).rejects.toThrow('activation record') + expect(vi.mocked(uploadRelayDirectory)).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts new file mode 100644 index 00000000000..1f8a4099a82 --- /dev/null +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -0,0 +1,340 @@ +/** + * Installing orcad on a host and, only if it proves itself, making it the active one. + * + * The install half is the relay's transaction, parameterized: the same per-version lock, + * staged SFTP write, `.install-complete` sentinel and stale-lock recovery, under + * `orcad-/` instead of `relay-/`. That is what §02 marks reusable. + * + * The activation half has no relay equivalent, because the relay has no notion of a version + * being *selected*. Bytes landing in a versioned directory neither picks a version nor rolls + * one back; the activation record does, and it is written only after the candidate publishes + * a health payload that survives `evaluateOrcadActivation`. A rejected candidate leaves the + * previous version running and its own bytes on disk — nothing is lost, and a retry costs no + * upload. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' +import { + abandonInstall, + computeRemoteInstallDir, + finalizeInstall, + isRemoteInstallComplete, + readLocalFullVersion +} from './ssh-relay-versioned-install' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_STATE_SNAPSHOT_DIR, + serializeOrcadActivationRecord, + withActivatedVersion, + type OrcadActivationRecord, + type OrcadStateSnapshot +} from './orcad-activation-record' +import { + orcadActivationPath, + readOrcadActivationRecord +} from './orcad-activation-record-store' +import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' +import { planOrcadUpdate, type OrcadTerminalCensus } from './orcad-update-plan' +import { + ORCAD_LOG_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput, + readOrcadReadinessCommand, + type OrcadLaunchSpec +} from './orcad-remote-launch' +import { + captureOrcadStateSnapshotCommand, + orcadSnapshotDirName, + parseOrcadSnapshotCapture +} from './orcad-state-snapshot' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' + +export type OrcadDeployOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + /** Local `out/orcad`, containing the artifacts and the `.version` marker. */ + localOrcadDir: string + nodePath: string + userDataDir: string + bindHost: string + port: number + /** + * Live-terminal counts, supplied by the caller from the runtime it is already connected + * to. Not probed here: counting the daemon's sessions needs its protocol, and a deploy + * that guessed zero from silence would be the "loss of contact means death" mistake. + */ + census: OrcadTerminalCensus + force?: boolean + readinessTimeoutMs?: number + now?: () => Date + sleep?: (ms: number) => Promise + signal?: AbortSignal +} + +export type OrcadDeployResult = + | { outcome: 'installed-and-activated'; fullVersion: string; verdict: OrcadActivationVerdict } + | { outcome: 'already-active'; fullVersion: string } + | { outcome: 'installed-not-activated'; fullVersion: string; code: string; reason: string } + +const DEFAULT_READINESS_TIMEOUT_MS = 90_000 +const READINESS_POLL_MS = 500 +const STOP_WAIT_SECONDS = 20 + +function exec( + options: OrcadDeployOptions, + command: string, + signal = options.signal +): Promise { + return execCommand(options.conn, command, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal + }) +} + +function baseDir(options: OrcadDeployOptions): string { + return joinRemotePath(options.host, options.remoteHome, RELAY_REMOTE_DIR) +} + +/** Install the bytes under `orcad-/`, using the relay's install transaction. */ +async function installOrcadBundle( + options: OrcadDeployOptions, + fullVersion: string, + remoteDir: string +): Promise { + if (await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { + signal: options.signal + })) { + return + } + await acquireInstallLock(options.conn, remoteDir, options.host, { signal: options.signal }) + try { + // Re-probe under the lock: a sibling deploy may have finished while we waited. + if ( + await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { + signal: options.signal + }) + ) { + return + } + await uploadRelayDirectory(options.conn, options.localOrcadDir, remoteDir, options.host, { + signal: options.signal + }) + await writeRelayFile( + options.conn, + options.host, + joinRemotePath(options.host, remoteDir, ORCAD_INSTALL_MODEL.versionFilename), + fullVersion, + { signal: options.signal } + ) + await finalizeInstall(options.conn, remoteDir, options.host, { signal: options.signal }) + } catch (error) { + // Leave a recoverable partial rather than a dir that probes complete. + await abandonInstall(options.conn, remoteDir, options.host) + throw error + } +} + +async function captureSnapshot( + options: OrcadDeployOptions, + fullVersion: string, + outgoingVersion: string | null, + takenAt: Date +): Promise { + const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime()) + const snapshotDir = joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, dirName) + const capture = parseOrcadSnapshotCapture( + await exec( + options, + captureOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) + ) + ) + if (capture === 'failed') { + throw new Error( + `Could not snapshot ${options.userDataDir} before activating ${fullVersion}. Orca's ` + + 'persisted state carries no schema version, so without a snapshot a rollback has no ' + + 'way back. Refusing to activate.' + ) + } + if (capture === 'empty') { + // Nothing on the host to lose: a first deployment. Rollback will correctly report that + // it has no snapshot, rather than restoring an archive of nothing over a populated root. + return null + } + return { + dirName, + takenBeforeVersion: fullVersion, + readableByVersion: outgoingVersion, + takenAt: takenAt.toISOString() + } +} + +async function launchAndAwaitReadiness( + options: OrcadDeployOptions, + spec: OrcadLaunchSpec +): Promise> { + await exec(options, orcadLaunchCommand(options.host, spec)) + const deadline = Date.now() + (options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS) + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) + let last = parseOrcadReadinessOutput('') + while (Date.now() < deadline) { + options.signal?.throwIfAborted() + last = parseOrcadReadinessOutput( + await exec(options, readOrcadReadinessCommand(options.host, spec.remoteInstallDir)) + ) + if (last.state !== 'pending') { + return last + } + await sleep(READINESS_POLL_MS) + } + return last +} + +/** + * Put the previous version back after a rejected candidate. + * + * Why this exists at all: activating means swapping which process owns the data root and the + * port, so the incumbent has to stop before the candidate can start. A gate that rejected + * and returned would leave the host with nothing running — a careful deploy causing the + * outage it was being careful about. The returned sentence goes into the caller's reason so + * the operator learns the host's actual state, not just why the candidate failed. + */ +async function restoreIncumbent( + options: OrcadDeployOptions, + record: OrcadActivationRecord, + candidateDir: string +): Promise { + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, candidateDir, { waitSeconds: STOP_WAIT_SECONDS }) + ) + ) + if (!orcadStopFreedTheHost(stopped)) { + return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + } + if (!record.active) { + return 'No previous version was active, so this host is now serving nothing.' + } + const incumbentDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + record.active + ) + const parsed = await launchAndAwaitReadiness(options, { + remoteInstallDir: incumbentDir, + nodePath: options.nodePath, + fullVersion: record.active, + userDataDir: options.userDataDir, + bindHost: options.bindHost, + port: options.port + }) + return parsed.state === 'ready' + ? `orcad ${record.active} was restarted and is serving again.` + : `orcad ${record.active} was relaunched but has not published readiness; this host may be down.` +} + +/** + * Install, then activate only on a green cross-process health verdict. + * + * Every early return past the install leaves the bytes on disk and the previous version + * serving, which is why they all report `installed-not-activated` rather than throwing: a + * refusal to switch is a successful outcome of a deploy that was asked to be careful. + */ +export async function deployOrcad(options: OrcadDeployOptions): Promise { + const now = options.now ?? ((): Date => new Date()) + const fullVersion = readLocalFullVersion(options.localOrcadDir) + const remoteDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, fullVersion) + const record = await readOrcadActivationRecord(options) + + await installOrcadBundle(options, fullVersion, remoteDir) + + const plan = planOrcadUpdate({ + record, + candidateVersion: fullVersion, + census: options.census, + ...(options.force !== undefined ? { force: options.force } : {}) + }) + if (plan.action === 'noop') { + return { outcome: 'already-active', fullVersion } + } + if (plan.action === 'defer') { + return { + outcome: 'installed-not-activated', + fullVersion, + code: plan.code, + reason: plan.reason + } + } + + const snapshot = record.active + ? await captureSnapshot(options, fullVersion, record.active, now()) + : null + + if (record.active) { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + record.active + ) + const stopped = parseOrcadStopOutcome( + await exec(options, stopOrcadCommand(options.host, outgoingDir, { + waitSeconds: STOP_WAIT_SECONDS + })) + ) + if (!orcadStopFreedTheHost(stopped)) { + return { + outcome: 'installed-not-activated', + fullVersion, + code: 'orcad_outgoing_stop_incomplete', + reason: + `orcad ${record.active} did not exit within ${STOP_WAIT_SECONDS}s of SIGTERM ` + + `(${stopped}). It is still holding the data root and the port, so the candidate ` + + 'cannot start. Not escalating to SIGKILL: that skips the shutdown that releases ' + + 'the instance lock, and the successor would then refuse to start.' + } + } + } + + const parsed = await launchAndAwaitReadiness(options, { + remoteInstallDir: remoteDir, + nodePath: options.nodePath, + fullVersion, + userDataDir: options.userDataDir, + bindHost: options.bindHost, + port: options.port + }) + const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { + buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), + fullVersion + }) + if (verdict.decision === 'reject') { + const restored = await restoreIncumbent(options, record, remoteDir) + return { + outcome: 'installed-not-activated', + fullVersion, + code: verdict.code, + reason: + `${verdict.reason} Candidate stderr is at ` + + `${joinRemotePath(options.host, remoteDir, ORCAD_LOG_FILENAME)}. ${restored}` + } + } + + await writeRelayFile( + options.conn, + options.host, + orcadActivationPath(options.host, options.remoteHome), + serializeOrcadActivationRecord(withActivatedVersion(record, fullVersion, snapshot, now())), + { signal: options.signal } + ) + return { outcome: 'installed-and-activated', fullVersion, verdict } +} diff --git a/src/main/ssh/orcad-remote-gc.test.ts b/src/main/ssh/orcad-remote-gc.test.ts new file mode 100644 index 00000000000..3716aaddaf9 --- /dev/null +++ b/src/main/ssh/orcad-remote-gc.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + isUnconfirmedSshCommandTermination: () => false +})) +vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('./ssh-relay-gc-claim', () => ({ + isRelayGcClaimOwned: vi.fn().mockResolvedValue(true), + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('token') +})) +vi.mock('./ssh-relay-gc-tombstone', () => ({ + cleanupRelayGcTombstones: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./ssh-relay-install-lock', () => ({ + RELAY_INSTALL_LOCK_NAME: '.install-lock', + isRelayInstallLockStale: vi.fn().mockResolvedValue(false) +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import { gcOldOrcadVersions } from './orcad-remote-gc' +import { emptyOrcadActivationRecord } from './orcad-activation-record' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import type { SshConnection } from './ssh-connection' + +const conn = {} as SshConnection +const host = getRemoteHostPlatform('linux-x64') +const mockExec = vi.mocked(execCommand) + +/** + * Drive one GC pass against a scripted host. `listing` is what the remote listing command + * returns; every other command answers from `responses`, defaulting to the happy path + * (unlocked, complete, dead) so a candidate is removed unless a test says otherwise. + */ +function scriptHost(options: { + listing: string[] + liveness?: Record + removed: string[] +}): void { + mockExec.mockImplementation(async (_conn, command: string) => { + if (command.includes('-mindepth 1 -maxdepth 1')) { + return options.listing.join('\n') + } + if (command.includes('.install-lock')) { + return 'OPEN' + } + if (command.includes('.install-complete')) { + return 'COMPLETE' + } + if (command.includes('.orcad-pid')) { + const dir = Object.keys(options.liveness ?? {}).find((name) => command.includes(name)) + return dir ? (options.liveness?.[dir] ?? 'DEAD') : 'DEAD' + } + if (command.startsWith('mv ')) { + const match = command.match(/'([^']*)'/) + if (match) { + options.removed.push(match[1].split('/').pop() ?? '') + } + return 'MOVED' + } + return '' + }) +} + +describe('orcad GC', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('scopes its remote listing to the orcad namespace', async () => { + const removed: string[] = [] + scriptHost({ listing: [], removed }) + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.2.0+bb', + record: emptyOrcadActivationRecord() + }) + const listCommand = mockExec.mock.calls.map((call) => String(call[1])).find((c) => c.includes('find')) + expect(listCommand).toContain("-name 'orcad-*'") + expect(listCommand).not.toContain("-name 'relay-*'") + }) + + it('leaves relay directories alone even when the host hands them to it', async () => { + // The host is lying — or a future listing bug widened the glob. GC must still refuse. + const removed: string[] = [] + scriptHost({ + listing: ['relay-0.1.0+aa', 'relay-0.0.9+ff', 'orcad-0.1.0+aa'], + removed + }) + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.2.0+bb', + record: emptyOrcadActivationRecord() + }) + expect(removed).toEqual(['orcad-0.1.0+aa']) + }) + + it('never removes the active or the previous version', async () => { + const removed: string[] = [] + scriptHost({ + listing: ['orcad-0.1.0+01d', 'orcad-0.2.0+9ee0', 'orcad-0.3.0+cc0'], + removed + }) + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', + record: { + ...emptyOrcadActivationRecord(), + active: '0.3.0+cc0', + previous: '0.2.0+9ee0' + } + }) + expect(removed).toEqual(['orcad-0.1.0+01d']) + }) + + it('never removes the version a live daemon was forked from', async () => { + const removed: string[] = [] + scriptHost({ listing: ['orcad-0.1.0+01d', 'orcad-0.0.9+01de'], removed }) + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', + record: { ...emptyOrcadActivationRecord(), active: '0.3.0+cc0' }, + liveDaemonVersion: '0.1.0+01d' + }) + expect(removed).toEqual(['orcad-0.0.9+01de']) + }) + + it('treats an unanswerable liveness probe as in use', async () => { + const removed: string[] = [] + scriptHost({ + listing: ['orcad-0.1.0+bb0', 'orcad-0.0.9+dead'], + liveness: { 'orcad-0.1.0+bb0': 'UNKNOWN', 'orcad-0.0.9+dead': 'DEAD' }, + removed + }) + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', + record: emptyOrcadActivationRecord() + }) + expect(removed).toEqual(['orcad-0.0.9+dead']) + }) +}) diff --git a/src/main/ssh/orcad-remote-gc.ts b/src/main/ssh/orcad-remote-gc.ts new file mode 100644 index 00000000000..a96b386fc1b --- /dev/null +++ b/src/main/ssh/orcad-remote-gc.ts @@ -0,0 +1,74 @@ +/** + * orcad's garbage collection, and the half of §06 falsifier 1 that says who owns it. + * + * **Each model GCs only its own namespace, permanently.** orcad removes `orcad-/` + * directories; the relay removes `relay-/` directories; neither ever removes the other's, + * and no plan item makes one the winner. That is not a migration compromise — the two models + * serve different users on the same machine (SSH target vs paired peer), so there is no + * moment at which one of them is entitled to clean up after the other. A pass that deleted + * the sibling's tree would be reaching across the execution boundary the whole design exists + * to keep intact. + * + * On top of the ownership rule, orcad pins three directories that are idle-looking but + * load-bearing: the active version, the rollback target, and whichever version the LIVE + * terminal daemon was forked from. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { gcOldRemoteInstallVersions } from './ssh-relay-versioned-install' +import { orcadGcPinnedDirNames, type OrcadActivationRecord } from './orcad-activation-record' +import { + orcadLivenessBlocksGc, + orcadLivenessProbeCommand, + parseOrcadLiveness +} from './orcad-remote-launch' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +export type OrcadGcOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + /** Absolute path of the version dir this client just used; never a candidate. */ + currentDirAbsPath: string + record: OrcadActivationRecord + /** + * The full version the live daemon's PID record names, when it can be read. + * + * An update preserves a daemon forked from the OUTGOING bundle whenever terminals are + * live, so this is routinely a version that is neither active nor previous. Deleting it + * would remove the tree under a running process. + */ + liveDaemonVersion?: string | null + signal?: AbortSignal +} + +export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise { + await gcOldRemoteInstallVersions( + options.conn, + ORCAD_INSTALL_MODEL, + options.remoteHome, + options.currentDirAbsPath, + options.host, + { + pinnedDirNames: orcadGcPinnedDirNames(options.record, options.liveDaemonVersion), + isDirLive: async (dir) => { + try { + const probe = await execCommand( + options.conn, + orcadLivenessProbeCommand(options.host, dir), + { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + } + ) + return orcadLivenessBlocksGc(parseOrcadLiveness(probe)) + } catch { + // Why true: an unanswered probe is not evidence a tree is idle. Same rule the + // relay's socket probe applies, for the same reason. + return true + } + } + } + ) +} diff --git a/src/main/ssh/orcad-remote-host-support.ts b/src/main/ssh/orcad-remote-host-support.ts new file mode 100644 index 00000000000..dfcb0f4b6e4 --- /dev/null +++ b/src/main/ssh/orcad-remote-host-support.ts @@ -0,0 +1,50 @@ +/** + * Which hosts the orcad launch/lifecycle path actually supports, declared rather than + * discovered at runtime. + * + * The install transaction is host-agnostic — it is the relay's, and the relay runs on + * Windows. The launch, liveness and stop path is not: it uses `nohup`, a redirected stdout, + * `kill -0` and `ps`. Emitting a PowerShell-shaped approximation of that would produce a + * deploy that reports success on a host where nothing is running. + */ +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +export class OrcadRemoteLaunchUnsupportedError extends Error { + readonly code = 'orcad_remote_launch_unsupported_host' + constructor(hostLabel: string) { + super( + `Deploying orcad to a ${hostLabel} host is not implemented. The install transaction is ` + + 'host-agnostic, but the launch and readiness path is POSIX-only: it uses nohup, a ' + + 'redirected stdout and `kill -0` liveness. Use the relay for this host.' + ) + this.name = 'OrcadRemoteLaunchUnsupportedError' + } +} + +export function assertPosixOrcadHost(host: RemoteHostPlatform): void { + if (isWindowsRemoteHost(host)) { + throw new OrcadRemoteLaunchUnsupportedError('Windows') + } +} + +/** PID of the launched orcad, written into its own version dir at launch. */ +export const ORCAD_PID_FILENAME = '.orcad-pid' + +/** + * A shell function answering whether a PID is a *running* process. + * + * `kill -0` alone is not that question. It succeeds for a zombie — a process that has + * exited but whose parent has not reaped it — so a stop loop built on it reports + * `STILL_RUNNING` for a process that is already gone, and GC reports a dead version dir as + * in use. Verified against a real zombie on macOS; the `ps` state check is what separates + * the two. + * + * A host without `ps` yields an empty state, which falls through to "alive" — the safe + * direction for both callers. + */ +export function posixProcessAliveShellFunction(): string { + return ( + 'orcad_alive() { kill -0 "$1" 2>/dev/null || return 1; ' + + 'case "$(ps -o stat= -p "$1" 2>/dev/null)" in Z*) return 1;; esac; return 0; };' + ) +} diff --git a/src/main/ssh/orcad-remote-launch.test.ts b/src/main/ssh/orcad-remote-launch.test.ts new file mode 100644 index 00000000000..068ae588f65 --- /dev/null +++ b/src/main/ssh/orcad-remote-launch.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' + +import { + ORCAD_READINESS_FILENAME, + orcadLaunchCommand, + orcadLivenessBlocksGc, + orcadLivenessProbeCommand, + OrcadRemoteLaunchUnsupportedError, + parseOrcadLiveness, + parseOrcadReadinessOutput +} from './orcad-remote-launch' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const posix = getRemoteHostPlatform('linux-x64') +const windows = getRemoteHostPlatform('win32-x64') + +const SPEC = { + remoteInstallDir: '/home/u/.orca-remote/orcad-0.2.0+bb01', + nodePath: '/usr/bin/node', + fullVersion: '0.2.0+bb01', + userDataDir: '/home/u/.orca', + bindHost: '127.0.0.1', + port: 7777 +} + +const READY_LINE = JSON.stringify({ + type: 'orca_server_ready', + schemaVersion: 1, + runtimeId: 'r1', + boundEndpoint: 'ws://127.0.0.1:7777', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled', + pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, + health: { buildHash: 'abc', terminalDaemon: { state: 'live' } } +}) + +describe('orcadLaunchCommand', () => { + it('states the bind posture rather than inheriting the build default', () => { + expect(orcadLaunchCommand(posix, SPEC)).toContain("--bind '127.0.0.1'") + }) + + it('truncates the readiness file, so a stale line cannot be activated on', () => { + const command = orcadLaunchCommand(posix, SPEC) + const truncate = command.indexOf(`: > '${SPEC.remoteInstallDir}/${ORCAD_READINESS_FILENAME}'`) + const launch = command.indexOf('nohup') + expect(truncate).toBeGreaterThan(-1) + expect(truncate).toBeLessThan(launch) + }) + + it('exports the version and the shared data root the deploy decided on', () => { + const command = orcadLaunchCommand(posix, SPEC) + expect(command).toContain(`ORCA_VERSION '${SPEC.fullVersion}'`.replace(' ', '=')) + expect(command).toContain(`ORCA_USER_DATA='${SPEC.userDataDir}'`) + }) + + it('declares the Windows refusal instead of emitting a command that cannot work', () => { + expect(() => orcadLaunchCommand(windows, SPEC)).toThrow(OrcadRemoteLaunchUnsupportedError) + }) +}) + +describe('readiness parsing', () => { + it('extracts the orca_server_ready payload', () => { + const parsed = parseOrcadReadinessOutput(`${READY_LINE}\n`) + expect(parsed).toMatchObject({ state: 'ready' }) + expect(parsed.state === 'ready' && parsed.readiness.boundEndpoint).toBe('ws://127.0.0.1:7777') + }) + + it('treats an empty or half-written file as pending, not as a failure', () => { + expect(parseOrcadReadinessOutput('')).toEqual({ state: 'pending' }) + expect(parseOrcadReadinessOutput('{"type":"orca_serv')).toEqual({ state: 'pending' }) + }) + + it('reports a complete JSON line that is not a readiness payload as malformed', () => { + expect(parseOrcadReadinessOutput('{"type":"something_else"}')).toMatchObject({ + state: 'malformed' + }) + }) +}) + +describe('liveness', () => { + it('reads the pid recorded in the version dir', () => { + expect(orcadLivenessProbeCommand(posix, SPEC.remoteInstallDir)).toContain('.orcad-pid') + }) + + it.each([ + ['LIVE', 'LIVE', true], + ['DEAD', 'DEAD', false], + ['', 'UNKNOWN', true], + ['garbage', 'UNKNOWN', true] + ])('parses %s and blocks GC = %s', (output, expected, blocks) => { + expect(parseOrcadLiveness(output)).toBe(expected) + expect(orcadLivenessBlocksGc(parseOrcadLiveness(output))).toBe(blocks) + }) +}) + +describe('stopping a running orcad', () => { + it('sends SIGTERM and never SIGKILL', () => { + const command = stopOrcadCommand(posix, SPEC.remoteInstallDir, { waitSeconds: 20 }) + expect(command).toContain('kill -TERM') + for (const kill of ['kill -9', 'kill -KILL', 'kill -SIGKILL', 'pkill']) { + expect(command).not.toContain(kill) + } + }) + + it.each([ + ['STOPPED', 'stopped', true], + ['ALREADY_EXITED', 'already-exited', true], + ['NO_PID', 'no-pid', true], + ['STILL_RUNNING', 'still-running', false], + ['SIGNAL_FAILED', 'signal-failed', false], + ['', 'unknown', false] + ])('parses %s and frees the host = %s', (output, expected, frees) => { + expect(parseOrcadStopOutcome(output)).toBe(expected) + expect(orcadStopFreedTheHost(parseOrcadStopOutcome(output))).toBe(frees) + }) +}) diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts new file mode 100644 index 00000000000..e82be95faec --- /dev/null +++ b/src/main/ssh/orcad-remote-launch.ts @@ -0,0 +1,178 @@ +/** + * Starting a candidate orcad on the host and reading back what it says about itself. + * + * This is the piece `docs/design/shipping-orcad.html` §02 marks **fork**, not reuse: the + * relay launches detached and proves itself by printing an `ORCA-RELAY` sentinel, and orcad + * has no such interface. It publishes a single `orca_server_ready` JSON line on stdout, + * carrying the health payload activation is gated on — so the handshake here is "capture + * that line", not "match a marker". + * + * The candidate is launched detached with stdout redirected to a file inside its own version + * directory. Reading readiness off the exec channel would mean holding the channel open for + * the process's whole life; redirecting means the deploy can disconnect and the supervisor + * still owns a running service. + */ +import { shellEscape } from './ssh-connection-utils' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { + assertPosixOrcadHost as assertPosixHost, + ORCAD_PID_FILENAME, + posixProcessAliveShellFunction +} from './orcad-remote-host-support' +import type { ServeReadiness } from '../server/serve-readiness' + +/** Stdout of the launched candidate: exactly one `orca_server_ready` line, then nothing. */ +export const ORCAD_READINESS_FILENAME = '.orcad-readiness' +/** Stderr, including the bind-exposure line and every supervision message. */ +export const ORCAD_LOG_FILENAME = 'orcad.log' +export { + ORCAD_PID_FILENAME, + OrcadRemoteLaunchUnsupportedError +} from './orcad-remote-host-support' + +export type OrcadLaunchSpec = { + remoteInstallDir: string + nodePath: string + fullVersion: string + /** Shared across versions, and the reason rollback needs a snapshot. */ + userDataDir: string + /** Loopback by default; the client reaches it through an SSH local port-forward. */ + bindHost: string + port: number +} + +/** + * Launch the candidate detached and echo its PID. + * + * Why `--bind` is always passed explicitly: orcad defaults to loopback, but a default is a + * thing a future version can change. The deploy states the posture it intends rather than + * inheriting whatever the installed build happens to default to. + */ +export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSpec): string { + assertPosixHost(host) + const dir = shellEscape(spec.remoteInstallDir) + const readiness = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_READINESS_FILENAME)) + const log = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LOG_FILENAME)) + const pidFile = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME)) + const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, 'orcad.js')) + return [ + `cd ${dir} &&`, + // Why truncate: a re-launch into a dir that already holds a previous readiness line would + // otherwise let the deploy activate on the OLD process's health payload. + `: > ${readiness} &&`, + 'umask 077 &&', + `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, + `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, + `nohup ${shellEscape(spec.nodePath)} ${entry}`, + `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, + `> ${readiness} 2>> ${log} < /dev/null &`, + `echo $! > ${pidFile} && cat ${pidFile}` + ].join(' ') +} + +export function readOrcadReadinessCommand( + host: RemoteHostPlatform, + remoteInstallDir: string +): string { + assertPosixHost(host) + const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) + return `cat ${readiness} 2>/dev/null || true` +} + +/** + * Is the process recorded in this version dir still running? + * + * Answers `LIVE`, `DEAD`, or `UNKNOWN`. `UNKNOWN` covers a missing or unparseable PID file + * and a `kill -0` that failed for a reason other than "no such process" — a permission + * error means someone else's process holds that PID, which is not evidence of death. + */ +export function orcadLivenessProbeCommand( + host: RemoteHostPlatform, + remoteInstallDir: string +): string { + assertPosixHost(host) + const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + return [ + posixProcessAliveShellFunction(), + `pid=$(cat ${pidFile} 2>/dev/null);`, + 'case "$pid" in', + '"" ) echo UNKNOWN;;', + '*[!0-9]* ) echo UNKNOWN;;', + // Why EPERM is LIVE and not DEAD: a permission error means some process holds that PID, + // and deleting a tree because we could not signal its owner is the wrong direction. + '* ) if orcad_alive "$pid"; then echo LIVE;', + 'elif kill -0 "$pid" 2>&1 | grep -qi "not permitted"; then echo LIVE;', + 'else echo DEAD; fi;;', + 'esac' + ].join(' ') +} + +export type OrcadLiveness = 'LIVE' | 'DEAD' | 'UNKNOWN' + +export function parseOrcadLiveness(output: string): OrcadLiveness { + const value = output.trim().split('\n').pop()?.trim() + return value === 'LIVE' || value === 'DEAD' ? value : 'UNKNOWN' +} + +/** True when GC must leave this directory alone. Inconclusive counts as in use. */ +export function orcadLivenessBlocksGc(liveness: OrcadLiveness): boolean { + return liveness !== 'DEAD' +} + +export type OrcadReadinessParse = + | { state: 'ready'; readiness: ServeReadiness } + | { state: 'pending' } + | { state: 'malformed'; reason: string } + +/** + * Pull the `orca_server_ready` payload out of whatever the candidate has written so far. + * + * Why scan for the type tag rather than parsing the last line: stdout is a file being + * appended to, so a poll can catch a half-written line. A partial JSON line is `pending`, + * not `malformed` — reporting a parse failure for a race would fail deploys that were fine. + */ +export function parseOrcadReadinessOutput(raw: string): OrcadReadinessParse { + const lines = raw.split('\n') + let sawCandidate = false + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) { + continue + } + sawCandidate = true + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + continue + } + if (typeof parsed !== 'object' || parsed === null) { + continue + } + const payload = parsed as { type?: unknown } + if (payload.type !== 'orca_server_ready') { + return { + state: 'malformed', + reason: `expected an orca_server_ready line, got type=${JSON.stringify(payload.type)}` + } + } + return { state: 'ready', readiness: toServeReadiness(payload as Record) } + } + return sawCandidate ? { state: 'pending' } : { state: 'pending' } +} + +function toServeReadiness(payload: Record): ServeReadiness { + return { + runtimeId: typeof payload.runtimeId === 'string' ? payload.runtimeId : '', + boundEndpoint: typeof payload.boundEndpoint === 'string' ? payload.boundEndpoint : null, + advertisedEndpoint: + typeof payload.advertisedEndpoint === 'string' ? payload.advertisedEndpoint : null, + managedWslCliReconciliation: + payload.managedWslCliReconciliation === 'pending' || + payload.managedWslCliReconciliation === 'failed' + ? payload.managedWslCliReconciliation + : 'settled', + pairing: payload.pairing as ServeReadiness['pairing'], + ...(payload.health ? { health: payload.health as ServeReadiness['health'] } : {}) + } +} diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts new file mode 100644 index 00000000000..6a9038ea3d6 --- /dev/null +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -0,0 +1,73 @@ +/** + * Stopping a running orcad on the host without taking its terminals with it. + * + * `SIGKILL` is absent on purpose. orcad's own escalation contract (`orcad-entry.ts`) is + * SIGTERM, then a second SIGTERM meaning "your deadline elapsed, exit now"; a kill skips the + * teardown that releases the instance lock and disconnects — rather than shuts down — the + * terminal daemon. The daemon is detached and would survive a kill, but a stop that leaves + * the lock file behind makes the successor refuse to start with + * `orcad_data_root_shared`, so the update turns into an outage for no gain. + */ +import { shellEscape } from './ssh-connection-utils' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { + assertPosixOrcadHost as assertPosixHost, + ORCAD_PID_FILENAME, + posixProcessAliveShellFunction +} from './orcad-remote-host-support' + +/** + * Signal the orcad recorded in a version dir and wait for it to go. + * + * `escalate` sends the second SIGTERM orcad reads as "exit immediately". Callers use it only + * after the first deadline elapses, so the two signals are never in the same command. + */ +export function stopOrcadCommand( + host: RemoteHostPlatform, + remoteInstallDir: string, + options: { waitSeconds: number } +): string { + assertPosixHost(host) + const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + return [ + posixProcessAliveShellFunction(), + `pid=$(cat ${pidFile} 2>/dev/null);`, + 'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;', + 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', + 'kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };', + `i=0; while [ "$i" -lt ${options.waitSeconds} ]; do`, + 'orcad_alive "$pid" || { echo STOPPED; exit 0; };', + 'sleep 1; i=$((i + 1)); done;', + 'echo STILL_RUNNING' + ].join(' ') +} + +export type OrcadStopOutcome = + | 'stopped' + | 'already-exited' + | 'no-pid' + | 'still-running' + | 'signal-failed' + | 'unknown' + +export function parseOrcadStopOutcome(output: string): OrcadStopOutcome { + switch (output.trim().split('\n').pop()?.trim() ?? '') { + case 'STOPPED': + return 'stopped' + case 'ALREADY_EXITED': + return 'already-exited' + case 'NO_PID': + return 'no-pid' + case 'STILL_RUNNING': + return 'still-running' + case 'SIGNAL_FAILED': + return 'signal-failed' + default: + return 'unknown' + } +} + +/** True when the port is free and a successor may bind. */ +export function orcadStopFreedTheHost(outcome: OrcadStopOutcome): boolean { + return outcome === 'stopped' || outcome === 'already-exited' || outcome === 'no-pid' +} diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts new file mode 100644 index 00000000000..59f3d46e330 --- /dev/null +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + isUnconfirmedSshCommandTermination: () => false +})) +vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('./ssh-relay-install-transfers', () => ({ + writeRelayFile: vi.fn().mockResolvedValue(undefined), + uploadRelayDirectory: vi.fn().mockResolvedValue(undefined) +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import { writeRelayFile } from './ssh-relay-install-transfers' +import { rollbackOrcad, type OrcadRollbackOptions } from './orcad-remote-rollback' +import { + emptyOrcadActivationRecord, + type OrcadActivationRecord +} from './orcad-activation-record' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import type { SshConnection } from './ssh-connection' + +const mockExec = vi.mocked(execCommand) +const ACTIVE = '0.2.0+bb01' +const TARGET = '0.1.0+aa01' +const BUILD_HASH = 'abc123def4567890' + +function record(overrides: Partial = {}): OrcadActivationRecord { + return { + ...emptyOrcadActivationRecord(), + active: ACTIVE, + previous: TARGET, + activatedAt: '2026-01-01T00:00:00.000Z', + snapshot: { + dirName: 'pre-0.2.0+bb01-1000', + takenBeforeVersion: ACTIVE, + readableByVersion: TARGET, + takenAt: '2026-01-01T00:00:00.000Z' + }, + ...overrides + } +} + +function readyLine(version: string): string { + return JSON.stringify({ + type: 'orca_server_ready', + schemaVersion: 1, + runtimeId: 'r1', + boundEndpoint: 'ws://127.0.0.1:7777', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled', + pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, + health: { + buildHash: BUILD_HASH, + buildVersion: version, + nodeVersion: '20.11.0', + nodeAbi: '115', + platform: 'linux', + arch: 'x64', + pid: 1, + terminalDaemon: { + state: 'live', + ownsFreshSessions: true, + pid: 2, + buildVersion: version, + entryPath: '/x/daemon-entry.js', + protocolVersion: 3, + selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 5 } + } + } + }) +} + +function scriptHost(log: string[], overrides: { restore?: string } = {}): void { + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { + return 'PRESENT' + } + if (text.includes('find ') && text.includes('stat')) { + return 'UNKNOWN' + } + if (text.includes('kill -TERM')) { + log.push(`stop:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) + return 'STOPPED' + } + if (text.includes('tar -C') && text.includes('-xf')) { + log.push('restore') + return overrides.restore ?? 'RESTORED' + } + if (text.includes('nohup')) { + log.push(`launch:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) + return '9999' + } + if (text.startsWith('cat ') && text.includes('.orcad-readiness')) { + return readyLine(TARGET) + } + return '' + }) +} + +function options(overrides: Partial = {}): OrcadRollbackOptions { + return { + conn: {} as SshConnection, + host: getRemoteHostPlatform('linux-x64'), + remoteHome: '/home/u', + record: record(), + nodePath: '/usr/bin/node', + userDataDir: '/home/u/.orca', + bindHost: '127.0.0.1', + port: 7777, + census: { liveSessions: 0, startedSinceActivation: 0 }, + targetBuildHash: BUILD_HASH, + readinessTimeoutMs: 50, + sleep: async () => {}, + now: () => new Date('2026-02-02T00:00:00.000Z'), + ...overrides + } +} + +describe('rollbackOrcad', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('stops, restores state, then starts the target — in that order', async () => { + const log: string[] = [] + scriptHost(log) + const result = await rollbackOrcad(options()) + expect(result).toMatchObject({ outcome: 'rolled-back', target: TARGET }) + // Restoring under a running orcad would replace the store beneath a process holding it; + // starting first would let the older build migrate the newer build's state. + expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) + }) + + it('refuses before touching anything when terminals started after activation', async () => { + const log: string[] = [] + scriptHost(log) + const result = await rollbackOrcad( + options({ census: { liveSessions: 3, startedSinceActivation: 2 } }) + ) + expect(result).toMatchObject({ + outcome: 'refused', + code: 'orcad_rollback_orphans_live_terminals' + }) + expect(log).toEqual([]) + expect(vi.mocked(writeRelayFile)).not.toHaveBeenCalled() + }) + + it('refuses when the snapshot is gone from the host', async () => { + const log: string[] = [] + mockExec.mockImplementation(async (_conn, command: string) => + String(command).includes('state.tar') ? 'ABSENT' : '' + ) + const result = await rollbackOrcad(options()) + expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_rollback_snapshot_missing' }) + expect(log).toEqual([]) + }) + + it('does not start the old build when the restore failed', async () => { + const log: string[] = [] + scriptHost(log, { restore: 'FAILED' }) + const result = await rollbackOrcad(options()) + expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_rollback_restore_failed' }) + expect(log).toEqual([`stop:${ACTIVE}`, 'restore']) + expect(result.outcome === 'failed' && result.reason).toContain('Do NOT start the older build') + }) + + it('leaves the record naming the newer version when the target fails to come up', async () => { + const log: string[] = [] + scriptHost(log) + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { + return 'PRESENT' + } + if (text.includes('kill -TERM')) { + return 'STOPPED' + } + if (text.includes('tar -C') && text.includes('-xf')) { + return 'RESTORED' + } + // The target never publishes readiness. + return '' + }) + const result = await rollbackOrcad(options()) + expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_activation_no_readiness' }) + // Until the target is proven serving, `active` must still name the version an operator + // would have to bring back. + expect(vi.mocked(writeRelayFile)).not.toHaveBeenCalled() + }) + + it('records the rollback only after the target answers healthy', async () => { + const log: string[] = [] + scriptHost(log) + await rollbackOrcad(options()) + const written = vi + .mocked(writeRelayFile) + .mock.calls.find((call) => String(call[2]).endsWith('orcad-active.json')) + expect(JSON.parse(String(written?.[3]))).toMatchObject({ + active: TARGET, + previous: null, + snapshot: null + }) + }) +}) diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts new file mode 100644 index 00000000000..165ccfc037f --- /dev/null +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -0,0 +1,244 @@ +/** + * Going back to the previously active orcad. + * + * Rollback is a state operation, not a binary swap. The version dirs are immutable and both + * are still on disk, so pointing at the old one is trivial; what is not trivial is that both + * versions share ONE data root, outside either dir. A newer orcad migrates that root on load + * — and Orca's persisted state carries no schema version to migrate against, so the older + * build cannot be shown to read the result. Rollback therefore restores the pre-activation + * snapshot, and refuses when restoring it would orphan work (`assessOrcadRollback`). + * + * The order below is the whole safety argument: stop, then restore, then start. Restoring + * under a running orcad would replace the store beneath a process holding it open, and + * starting before restoring would let the old build migrate the new build's state — the + * failure this is meant to avoid, arrived at from the other side. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { writeRelayFile } from './ssh-relay-install-transfers' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_STATE_SNAPSHOT_DIR, + serializeOrcadActivationRecord, + withRolledBackVersion, + type OrcadActivationRecord +} from './orcad-activation-record' +import { assessOrcadRollback, type OrcadTerminalCensus } from './orcad-update-plan' +import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' +import { + ORCAD_LOG_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput, + readOrcadReadinessCommand +} from './orcad-remote-launch' +import { + newestStateMtimeCommand, + parseNewestStateMtimeSeconds, + parseOrcadSnapshotRestore, + probeOrcadStateSnapshotCommand, + restoreOrcadStateSnapshotCommand +} from './orcad-state-snapshot' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { orcadActivationPath } from './orcad-activation-record-store' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +export type OrcadRollbackOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + record: OrcadActivationRecord + nodePath: string + userDataDir: string + bindHost: string + port: number + census: OrcadTerminalCensus + /** Expected build hash of the rollback target, from the client's copy of those bytes. */ + targetBuildHash: string + readinessTimeoutMs?: number + now?: () => Date + sleep?: (ms: number) => Promise + signal?: AbortSignal +} + +export type OrcadRollbackResult = + | { outcome: 'rolled-back'; target: string; discarded: string[]; verdict: OrcadActivationVerdict } + | { outcome: 'refused'; code: string; reason: string } + | { outcome: 'failed'; code: string; reason: string } + +const DEFAULT_READINESS_TIMEOUT_MS = 90_000 +const READINESS_POLL_MS = 500 +const STOP_WAIT_SECONDS = 20 + +function exec(options: OrcadRollbackOptions, command: string): Promise { + return execCommand(options.conn, command, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }) +} + +function snapshotDirPath(options: OrcadRollbackOptions, dirName: string): string { + return joinRemotePath( + options.host, + options.remoteHome, + RELAY_REMOTE_DIR, + ORCAD_STATE_SNAPSHOT_DIR, + dirName + ) +} + +/** Has the store been written since activation? `null` when it cannot be established. */ +async function readStateWritesSinceActivation( + options: OrcadRollbackOptions +): Promise { + if (!options.record.activatedAt) { + return null + } + const activatedAtSeconds = Math.floor(Date.parse(options.record.activatedAt) / 1000) + if (!Number.isFinite(activatedAtSeconds)) { + return null + } + const newest = parseNewestStateMtimeSeconds( + await exec(options, newestStateMtimeCommand(options.host, options.userDataDir)).catch(() => '') + ) + return newest === null ? null : newest >= activatedAtSeconds +} + +export async function rollbackOrcad( + options: OrcadRollbackOptions +): Promise { + const now = options.now ?? ((): Date => new Date()) + const snapshotPresent = options.record.snapshot + ? ( + await exec( + options, + probeOrcadStateSnapshotCommand( + options.host, + snapshotDirPath(options, options.record.snapshot.dirName) + ) + ).catch(() => 'ABSENT') + ).trim() === 'PRESENT' + : false + + const safety = assessOrcadRollback({ + record: options.record, + snapshotPresent, + census: options.census, + stateWritesSinceActivation: await readStateWritesSinceActivation(options) + }) + if (safety.safety === 'unsafe') { + return { outcome: 'refused', code: safety.code, reason: safety.reason } + } + + if (options.record.active) { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + options.record.active + ) + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, outgoingDir, { waitSeconds: STOP_WAIT_SECONDS }) + ) + ) + if (!orcadStopFreedTheHost(stopped)) { + return { + outcome: 'failed', + code: 'orcad_rollback_stop_incomplete', + reason: + `orcad ${options.record.active} did not exit within ${STOP_WAIT_SECONDS}s of SIGTERM ` + + `(${stopped}). Nothing was restored — the store is untouched and the host is still ` + + 'serving the version you tried to leave.' + } + } + } + + // Why between stop and start: the store must be replaced while no orcad holds it, and + // before the older build gets a chance to migrate the newer build's state. + const restored = parseOrcadSnapshotRestore( + await exec( + options, + restoreOrcadStateSnapshotCommand( + options.host, + options.userDataDir, + // Guarded by `assessOrcadRollback`: `unsafe` covers a missing snapshot. + snapshotDirPath(options, options.record.snapshot?.dirName ?? '') + ) + ).catch(() => 'FAILED') + ) + if (restored !== 'restored') { + return { + outcome: 'failed', + code: 'orcad_rollback_restore_failed', + reason: + `The pre-activation snapshot could not be restored (${restored}). orcad is stopped and ` + + 'the data root may be partially replaced. Do NOT start the older build against it; ' + + `re-deploy ${options.record.active ?? 'the newer version'}, which can read what is there.` + } + } + + const targetDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + safety.target + ) + await exec( + options, + orcadLaunchCommand(options.host, { + remoteInstallDir: targetDir, + nodePath: options.nodePath, + fullVersion: safety.target, + userDataDir: options.userDataDir, + bindHost: options.bindHost, + port: options.port + }) + ) + const deadline = Date.now() + (options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS) + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) + let parsed = parseOrcadReadinessOutput('') + while (Date.now() < deadline && parsed.state === 'pending') { + options.signal?.throwIfAborted() + parsed = parseOrcadReadinessOutput( + await exec(options, readOrcadReadinessCommand(options.host, targetDir)) + ) + if (parsed.state === 'pending') { + await sleep(READINESS_POLL_MS) + } + } + const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { + buildHash: options.targetBuildHash, + fullVersion: safety.target + }) + if (verdict.decision === 'reject') { + return { + outcome: 'failed', + code: verdict.code, + reason: + `The rollback target ${safety.target} did not come up healthy: ${verdict.reason} The ` + + `store has been restored to its pre-activation state. Its stderr is at ` + + `${joinRemotePath(options.host, targetDir, ORCAD_LOG_FILENAME)}.` + } + } + + // Why the record is written last: until the target is proven serving, `active` still names + // the version an operator would need to bring back, and `previous` still names this target. + await writeRelayFile( + options.conn, + options.host, + orcadActivationPath(options.host, options.remoteHome), + serializeOrcadActivationRecord(withRolledBackVersion(options.record, now())), + { signal: options.signal } + ) + return { + outcome: 'rolled-back', + target: safety.target, + discarded: safety.safety === 'lossy' ? safety.discards : [], + verdict + } +} diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts new file mode 100644 index 00000000000..d1f8ac0048f --- /dev/null +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -0,0 +1,189 @@ +/** + * Runs the generated POSIX commands through a real `/bin/sh`. + * + * The unit tests assert on command *text*, which is exactly the kind of test that stays + * green while the shell it produces does not work — a quoting slip, a `case` pattern that + * never matches, a `tar` invocation that silently captures nothing. These run the strings. + */ +import { execFileSync, spawn } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + orcadLivenessProbeCommand, + ORCAD_PID_FILENAME, + parseOrcadLiveness +} from './orcad-remote-launch' +import { parseOrcadStopOutcome, stopOrcadCommand } from './orcad-remote-process-control' +import { + captureOrcadStateSnapshotCommand, + newestStateMtimeCommand, + parseNewestStateMtimeSeconds, + parseOrcadSnapshotCapture, + parseOrcadSnapshotRestore, + probeOrcadStateSnapshotCommand, + restoreOrcadStateSnapshotCommand +} from './orcad-state-snapshot' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const host = getRemoteHostPlatform('linux-x64') +let root = '' +let dataDir = '' +let snapshotDir = '' +let versionDir = '' + +function sh(command: string): string { + return execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orcad-shell-')) + dataDir = join(root, '.orca') + snapshotDir = join(root, 'snapshots', 'pre-0.2.0+bb01-1000') + versionDir = join(root, '.orca-remote', 'orcad-0.2.0+bb01') + mkdirSync(join(dataDir, 'profiles', 'p1'), { recursive: true }) + mkdirSync(join(dataDir, 'daemon'), { recursive: true }) + mkdirSync(versionDir, { recursive: true }) + writeFileSync(join(dataDir, 'orca-profile-index.json'), '{"v":"before"}') + writeFileSync(join(dataDir, 'profiles', 'p1', 'orca-data.json'), '{"repos":"before"}') + writeFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'live-daemon-token') +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('state snapshot commands, run for real', () => { + it('captures, then restores state the newer build overwrote', () => { + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('captured') + expect(sh(probeOrcadStateSnapshotCommand(host, snapshotDir)).trim()).toBe('PRESENT') + + // The new version migrates the store and adds a file of its own. + writeFileSync(join(dataDir, 'orca-profile-index.json'), '{"v":"migrated"}') + writeFileSync(join(dataDir, 'profiles', 'p1', 'new-build-only.json'), '{}') + + expect( + parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('restored') + expect(readFileSync(join(dataDir, 'orca-profile-index.json'), 'utf8')).toBe('{"v":"before"}') + // Removed before extraction, so the older build never sees a file it cannot interpret. + expect(() => readFileSync(join(dataDir, 'profiles', 'p1', 'new-build-only.json'))).toThrow() + }) + + it('leaves the live daemon runtime dir untouched through capture and restore', () => { + sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) + // The daemon is running across the rollback and rewrites its token; a restore that + // reached /daemon would break the fence that keeps its terminals adoptable. + writeFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'token-after-restart') + sh(restoreOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) + expect(readFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'utf8')).toBe( + 'token-after-restart' + ) + }) + + it('reports EMPTY on a data root with nothing to lose, instead of an archive of nothing', () => { + const emptyRoot = join(root, 'fresh') + mkdirSync(emptyRoot) + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, emptyRoot, snapshotDir))) + ).toBe('empty') + expect(sh(probeOrcadStateSnapshotCommand(host, snapshotDir)).trim()).toBe('ABSENT') + }) + + it('reports MISSING rather than claiming a restore it did not perform', () => { + expect( + parseOrcadSnapshotRestore( + sh(restoreOrcadStateSnapshotCommand(host, dataDir, join(root, 'nope'))) + ) + ).toBe('missing') + }) + + it('reads a real mtime for the store', () => { + const seconds = parseNewestStateMtimeSeconds(sh(newestStateMtimeCommand(host, dataDir))) + expect(seconds).toBeGreaterThan(1_600_000_000) + expect(seconds).toBeLessThanOrEqual(Math.floor(Date.now() / 1000) + 5) + }) + + it('survives a data root whose path contains a quote and a space', () => { + const nasty = join(root, `it's a dir`) + mkdirSync(join(nasty, 'profiles'), { recursive: true }) + writeFileSync(join(nasty, 'orca-profile-index.json'), '{"v":"quoted"}') + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, nasty, snapshotDir))) + ).toBe('captured') + writeFileSync(join(nasty, 'orca-profile-index.json'), '{"v":"changed"}') + expect( + parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, nasty, snapshotDir))) + ).toBe('restored') + expect(readFileSync(join(nasty, 'orca-profile-index.json'), 'utf8')).toBe('{"v":"quoted"}') + }) +}) + +describe('liveness and stop commands, run for real', () => { + it('reports UNKNOWN with no pid file, and DEAD for a pid that has exited', () => { + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('UNKNOWN') + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), 'not-a-pid') + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('UNKNOWN') + // A pid that has certainly exited: our own `sh` child from the line above. + const exited = Number(sh('sh -c "echo $$"').trim()) + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(exited)) + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') + }) + + it('reports LIVE for a running process and stops it with SIGTERM', async () => { + const child = spawn('/bin/sh', ['-c', 'sleep 30'], { stdio: 'ignore' }) + try { + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(child.pid)) + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') + + const exited = new Promise((resolve) => + child.once('exit', (_code, signal) => resolve(signal)) + ) + expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10 })))).toBe( + 'stopped' + ) + expect(await exited).toBe('SIGTERM') + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') + } finally { + child.kill('SIGKILL') + } + }) + + // `kill -0` succeeds on a zombie, so a probe built on it alone calls an exited process + // live: the stop loop would time out on a process that is already gone, and GC would keep + // a dead version dir forever. Verified as a real macOS behaviour, not a hypothetical. + it('reports a zombie as DEAD, not as a running process', () => { + const child = spawn('/bin/sh', ['-c', 'exit 0'], { stdio: 'ignore' }) + try { + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(child.pid)) + // Block the event loop so Node never reaps it; the process is now a zombie. + sh('sleep 1') + expect(sh(`ps -o stat= -p ${child.pid} || echo GONE`).trim()).toMatch(/^Z/) + expect(sh(`kill -0 ${child.pid} 2>/dev/null && echo LIVE || echo DEAD`).trim()).toBe('LIVE') + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') + expect( + parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 }))) + ).toBe('already-exited') + } finally { + child.unref() + } + }) + + it('reports ALREADY_EXITED for a stale pid file rather than signalling a stranger', () => { + const exited = Number(sh('sh -c "echo $$"').trim()) + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(exited)) + expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe( + 'already-exited' + ) + }) + + it('reports NO_PID when the version dir was never launched', () => { + expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe( + 'no-pid' + ) + }) +}) diff --git a/src/main/ssh/orcad-state-snapshot.test.ts b/src/main/ssh/orcad-state-snapshot.test.ts new file mode 100644 index 00000000000..0c50dc55fdb --- /dev/null +++ b/src/main/ssh/orcad-state-snapshot.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' + +import { + ORCAD_SNAPSHOT_EXCLUDED, + ORCAD_SNAPSHOT_MEMBERS, + captureOrcadStateSnapshotCommand, + newestStateMtimeCommand, + orcadSnapshotDirName, + parseNewestStateMtimeSeconds, + parseOrcadSnapshotCapture, + parseOrcadSnapshotRestore, + restoreOrcadStateSnapshotCommand +} from './orcad-state-snapshot' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const posix = getRemoteHostPlatform('linux-x64') +const windows = getRemoteHostPlatform('win32-x64') +const ROOT = '/home/u/.orca' +const SNAP = '/home/u/.orca-remote/orcad-state-snapshots/pre-0.2.0+bb01-1000' + +describe('capturing the pre-activation snapshot', () => { + it('captures the profile state a rollback needs', () => { + const command = captureOrcadStateSnapshotCommand(posix, ROOT, SNAP) + for (const member of ORCAD_SNAPSHOT_MEMBERS) { + expect(command).toContain(`'${member}'`) + } + }) + + // The live daemon owns /daemon and outlives every restart. Restoring a stale copy of + // its socket, PID record and token would break the fence that keeps its terminals adoptable. + it.each(ORCAD_SNAPSHOT_EXCLUDED)('never captures %s', (excluded) => { + expect(captureOrcadStateSnapshotCommand(posix, ROOT, SNAP)).not.toContain(`'${excluded}'`) + }) + + it.each(ORCAD_SNAPSHOT_EXCLUDED)('never removes or restores over %s', (excluded) => { + expect(restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP)).not.toContain(`'${excluded}'`) + }) + + it('writes the archive under a temp name and renames, so a killed deploy leaves no torn tar', () => { + const command = captureOrcadStateSnapshotCommand(posix, ROOT, SNAP) + expect(command).toContain('.partial') + expect(command.indexOf('tar -C')).toBeLessThan(command.indexOf('mv ')) + }) + + it.each([ + ['CAPTURED', 'captured'], + ['EMPTY', 'empty'], + ['tar: broken', 'failed'], + ['', 'failed'] + ])('parses %s as %s', (output, expected) => { + expect(parseOrcadSnapshotCapture(output)).toBe(expected) + }) + + it('keys the snapshot dir on both version and time, so a retry cannot overwrite one', () => { + expect(orcadSnapshotDirName('0.2.0+bb01', 1000)).not.toBe( + orcadSnapshotDirName('0.2.0+bb01', 2000) + ) + }) +}) + +describe('restoring the snapshot', () => { + it('clears the members before extracting, so files the new build added do not survive', () => { + const command = restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP) + expect(command.indexOf('rm -rf')).toBeLessThan(command.indexOf('tar -C')) + }) + + it('reports a missing archive instead of extracting nothing and claiming success', () => { + expect(restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP)).toContain('echo MISSING') + expect(parseOrcadSnapshotRestore('MISSING')).toBe('missing') + expect(parseOrcadSnapshotRestore('RESTORED')).toBe('restored') + expect(parseOrcadSnapshotRestore('FAILED')).toBe('failed') + }) +}) + +describe('detecting writes since activation', () => { + it.each([ + ['1700000000', 1_700_000_000], + ['UNKNOWN', null], + ['', null] + ])('parses %s', (output, expected) => { + expect(parseNewestStateMtimeSeconds(output)).toBe(expected) + }) + + it('looks at the same members the snapshot covers', () => { + const command = newestStateMtimeCommand(posix, ROOT) + for (const member of ORCAD_SNAPSHOT_MEMBERS) { + expect(command).toContain(`'${member}'`) + } + }) +}) + +describe('Windows hosts', () => { + it.each([ + ['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)], + ['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)], + ['mtime', () => newestStateMtimeCommand(windows, ROOT)] + ])('refuses %s rather than emitting a POSIX command', (_label, build) => { + expect(build).toThrow('orcad to a Windows host is not implemented') + }) +}) diff --git a/src/main/ssh/orcad-state-snapshot.ts b/src/main/ssh/orcad-state-snapshot.ts new file mode 100644 index 00000000000..78ad8b47d2a --- /dev/null +++ b/src/main/ssh/orcad-state-snapshot.ts @@ -0,0 +1,173 @@ +/** + * The pre-activation copy of shared profile state that makes rollback sound. + * + * `docs/design/shipping-orcad.html` §04's state-schema row asks for "backward-readable + * migrations or a pre-activation snapshot". Only the second is available here, and not as a + * preference: Orca's persisted state carries **no schema version**. Migrations are cohort + * and shape heuristics that run on load and rewrite in place, and the load path rebuilds + * `settings` and `ui` from known fields — so a newer build's nested additions are silently + * dropped by an older one rather than rejected. There is nothing to compare and nothing that + * fails loudly, which rules out proving backward-readability and leaves the snapshot. + * + * What is snapshotted is deliberately narrow. `/daemon` is EXCLUDED: it holds the live + * daemon's socket, PID record and auth token, and that daemon outlives every orcad restart + * by design. Restoring a stale copy of it over a running daemon would break the endpoint + * fence that keeps its terminals adoptable — turning a rollback into the exact terminal + * massacre the daemon exists to prevent. + */ +import { shellEscape } from './ssh-connection-utils' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { assertPosixOrcadHost as assertPosixHost } from './orcad-remote-host-support' + +/** + * Root-relative paths a rollback needs restored. Everything else under the data root is + * either regenerable, or owned by a process that survives the rollback. + */ +export const ORCAD_SNAPSHOT_MEMBERS = [ + 'orca-profile-index.json', + // Pre-profiles layout; still read as a migration source. + 'orca-data.json', + 'profiles' +] as const + +/** Never captured and never restored — see the module comment. */ +export const ORCAD_SNAPSHOT_EXCLUDED = ['daemon', 'logs'] as const + +/** + * The member names go into the command unquoted (see `captureOrcadStateSnapshotCommand`), so + * they must be inert. They are compile-time constants; this catches the edit that adds one + * with a space or a metacharacter in it. + */ +function assertPlainMemberName(member: string): string { + if (!/^[A-Za-z0-9._-]+$/.test(member)) { + throw new Error(`Unsafe orcad snapshot member name: ${JSON.stringify(member)}`) + } + return member +} + +export function orcadSnapshotDirName(fullVersion: string, takenAtMs: number): string { + // Why the version and the timestamp: two activations of one version (a re-deploy after a + // rejected activation) must not overwrite each other's snapshot. + return `pre-${fullVersion}-${takenAtMs}` +} + +/** + * Capture the snapshot, or report why there is nothing to capture. + * + * Prints `CAPTURED`, or `EMPTY` when the data root holds none of the members — a first-ever + * deployment, where there is no state to lose and therefore no snapshot to take. `EMPTY` is + * reported rather than fabricating an empty archive, because a rollback that "restored" an + * empty archive would wipe a root that had filled up in between. + */ +export function captureOrcadStateSnapshotCommand( + host: RemoteHostPlatform, + userDataDir: string, + snapshotDir: string +): string { + assertPosixHost(host) + const root = shellEscape(userDataDir) + const dir = shellEscape(snapshotDir) + const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + const memberTests = ORCAD_SNAPSHOT_MEMBERS.map( + // Why the accumulated name is NOT quoted: `$members` is re-split by the shell before it + // reaches tar, so a quoted name arrives as a literal `'profiles'` that tar cannot stat. + // `assertPlainMemberName` is what makes leaving them bare safe. + (member) => + `[ -e ${root}/${shellEscape(member)} ] && members="$members ${assertPlainMemberName(member)}";` + ).join(' ') + return [ + `members=;`, + memberTests, + 'if [ -z "$members" ]; then echo EMPTY; else', + `mkdir -p ${dir} && umask 077 &&`, + // Why a temp name then mv: a deploy killed mid-tar must not leave a truncated archive + // that a later rollback would happily restore. + `tar -C ${root} -cf ${archive}.partial $members && mv ${archive}.partial ${archive} &&`, + 'echo CAPTURED; fi' + ].join(' ') +} + +export type OrcadSnapshotCapture = 'captured' | 'empty' | 'failed' + +export function parseOrcadSnapshotCapture(output: string): OrcadSnapshotCapture { + const value = output.trim().split('\n').pop()?.trim() + if (value === 'CAPTURED') { + return 'captured' + } + return value === 'EMPTY' ? 'empty' : 'failed' +} + +export function probeOrcadStateSnapshotCommand( + host: RemoteHostPlatform, + snapshotDir: string +): string { + assertPosixHost(host) + const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + return `test -f ${archive} && echo PRESENT || echo ABSENT` +} + +/** + * Restore the snapshot over the data root. + * + * Two things make this safe to run: the members are removed before extraction (so a file the + * new version added is gone rather than half-shadowed), and neither the removal nor the + * extraction can reach `/daemon`, because the member list never names it. + * + * The caller must have stopped orcad first. This does not check — it cannot, from a shell — + * so `orcad-remote-deploy.ts` owns that ordering. + */ +export function restoreOrcadStateSnapshotCommand( + host: RemoteHostPlatform, + userDataDir: string, + snapshotDir: string +): string { + assertPosixHost(host) + const root = shellEscape(userDataDir) + const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + const removals = ORCAD_SNAPSHOT_MEMBERS.map( + (member) => `rm -rf ${root}/${shellEscape(member)};` + ).join(' ') + return [ + `test -f ${archive} || { echo MISSING; exit 0; };`, + `test -d ${root} || mkdir -p ${root};`, + removals, + `tar -C ${root} -xf ${archive} && echo RESTORED || echo FAILED` + ].join(' ') +} + +export type OrcadSnapshotRestore = 'restored' | 'missing' | 'failed' + +export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore { + const value = output.trim().split('\n').pop()?.trim() + if (value === 'RESTORED') { + return 'restored' + } + return value === 'MISSING' ? 'missing' : 'failed' +} + +/** + * Has the shared store been written since `activatedAt`? + * + * Prints the newest mtime (epoch seconds) across the snapshot members, or `UNKNOWN`. The + * caller compares; an `UNKNOWN` becomes `null`, which `assessOrcadRollback` treats as "yes, + * assume writes". + */ +export function newestStateMtimeCommand(host: RemoteHostPlatform, userDataDir: string): string { + assertPosixHost(host) + const root = shellEscape(userDataDir) + const paths = ORCAD_SNAPSHOT_MEMBERS.map((member) => `${root}/${shellEscape(member)}`).join(' ') + return [ + `newest=$(find ${paths} -type f -exec stat -c %Y {} + 2>/dev/null ||`, + `find ${paths} -type f -exec stat -f %m {} + 2>/dev/null);`, + 'if [ -z "$newest" ]; then echo UNKNOWN; else', + `echo "$newest" | sort -n | tail -1; fi` + ].join(' ') +} + +export function parseNewestStateMtimeSeconds(output: string): number | null { + const value = output.trim().split('\n').pop()?.trim() + if (!value || !/^\d+$/.test(value)) { + return null + } + return Number.parseInt(value, 10) +} diff --git a/src/main/ssh/orcad-update-plan.test.ts b/src/main/ssh/orcad-update-plan.test.ts new file mode 100644 index 00000000000..5d90c597e3c --- /dev/null +++ b/src/main/ssh/orcad-update-plan.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' + +import { assessOrcadRollback, planOrcadUpdate } from './orcad-update-plan' +import { + emptyOrcadActivationRecord, + type OrcadActivationRecord, + type OrcadStateSnapshot +} from './orcad-activation-record' + +const SNAPSHOT: OrcadStateSnapshot = { + dirName: 'pre-0.2.0+bb01-1000', + takenBeforeVersion: '0.2.0+bb01', + readableByVersion: '0.1.0+aa01', + takenAt: '2026-01-01T00:00:00.000Z' +} + +function record(overrides: Partial = {}): OrcadActivationRecord { + return { + ...emptyOrcadActivationRecord(), + active: '0.2.0+bb01', + previous: '0.1.0+aa01', + activatedAt: '2026-01-01T00:00:01.000Z', + snapshot: SNAPSHOT, + ...overrides + } +} + +describe('planOrcadUpdate', () => { + it('does nothing when the candidate is already active', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.2.0+bb01', + census: { liveSessions: 0, startedSinceActivation: 0 } + }) + expect(plan).toMatchObject({ action: 'noop' }) + }) + + it('defers rather than restarting a host with live terminals', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + census: { liveSessions: 3, startedSinceActivation: 1 } + }) + expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_terminals_running' }) + expect(plan.action === 'defer' && plan.reason).toContain('would not kill them') + }) + + it('defers when the session count cannot be established', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + census: { liveSessions: null, startedSinceActivation: null } + }) + expect(plan).toMatchObject({ + action: 'defer', + code: 'orcad_update_terminal_census_unavailable' + }) + }) + + it('plans a forced update with an unknown census as if terminals were live', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + census: { liveSessions: null, startedSinceActivation: null }, + force: true + }) + expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) + }) + + it('carries the daemon across a forced update with live terminals', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + census: { liveSessions: 2, startedSinceActivation: 0 }, + force: true + }) + expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) + }) + + it('replaces the daemon only when nothing is running under it', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + census: { liveSessions: 0, startedSinceActivation: 0 } + }) + expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: false }) + }) +}) + +describe('assessOrcadRollback', () => { + it('is clean when the snapshot is intact and nothing happened since activation', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: { liveSessions: 0, startedSinceActivation: 0 }, + stateWritesSinceActivation: false + }) + expect(safety).toMatchObject({ safety: 'clean', target: '0.1.0+aa01' }) + }) + + it('is lossy, and names what goes, once the store has been written since activation', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: { liveSessions: 1, startedSinceActivation: 0 }, + stateWritesSinceActivation: true + }) + expect(safety).toMatchObject({ safety: 'lossy', target: '0.1.0+aa01' }) + expect(safety.safety === 'lossy' && safety.discards[0]).toContain('2026-01-01T00:00:01.000Z') + }) + + it('treats an unreadable store mtime as writes, not as a clean rollback', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: { liveSessions: 0, startedSinceActivation: 0 }, + stateWritesSinceActivation: null + }) + expect(safety).toMatchObject({ safety: 'lossy' }) + }) + + // The point past which rollback is unsafe: the first terminal created after activation. + it('refuses once a terminal started after activation, because restoring would orphan it', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: { liveSessions: 4, startedSinceActivation: 1 }, + stateWritesSinceActivation: true + }) + expect(safety).toMatchObject({ + safety: 'unsafe', + code: 'orcad_rollback_orphans_live_terminals' + }) + expect(safety.safety === 'unsafe' && safety.reason).toContain('nothing would be able to') + }) + + it('refuses when the snapshot the record names is gone from the host', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: false, + census: { liveSessions: 0, startedSinceActivation: 0 }, + stateWritesSinceActivation: false + }) + expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) + expect(safety.safety === 'unsafe' && safety.reason).toContain('no schema version') + }) + + it('refuses when no snapshot was ever recorded', () => { + const safety = assessOrcadRollback({ + record: record({ snapshot: null }), + snapshotPresent: true, + census: { liveSessions: 0, startedSinceActivation: 0 }, + stateWritesSinceActivation: false + }) + expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) + }) + + it('refuses when the post-activation session count is unverifiable', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: { liveSessions: 2, startedSinceActivation: null }, + stateWritesSinceActivation: false + }) + expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_census_unavailable' }) + }) + + it('refuses when there is no previous version to go back to', () => { + const safety = assessOrcadRollback({ + record: record({ previous: null }), + snapshotPresent: true, + census: { liveSessions: 0, startedSinceActivation: 0 }, + stateWritesSinceActivation: false + }) + expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_no_target' }) + }) +}) diff --git a/src/main/ssh/orcad-update-plan.ts b/src/main/ssh/orcad-update-plan.ts new file mode 100644 index 00000000000..2653956ebe5 --- /dev/null +++ b/src/main/ssh/orcad-update-plan.ts @@ -0,0 +1,234 @@ +/** + * When an update may restart orcad, and when going back is still sound. + * + * Two constraints shape everything here. + * + * **The daemon must outlive the restart.** orcad forks the terminal daemon and deliberately + * does not kill it on stop (`orcad-daemon-supervision.ts` uses `disconnectDaemon`, never + * `shutdownDaemon`). An update that killed it would destroy every terminal on the host — + * the thing the daemon exists to prevent. After an update the surviving daemon was forked + * from the OUTGOING bundle, so `daemon-init` sees an entry-path/version mismatch and takes + * its `shouldPreserveDaemonWithLiveSessions` branch: with live sessions it preserves the old + * daemon; at exactly zero it replaces it. Both are correct, and both mean the outgoing + * version's directory is still load-bearing. + * + * **The state root is shared across versions.** `~/.orca/` (or `$ORCA_USER_DATA`) is outside + * every version dir, and Orca's persisted state carries no schema version — migrations run + * on load and rewrite in place. So "is the old version able to read what the new one wrote" + * has no answer that can be computed. That is why rollback is defined against a + * pre-activation snapshot rather than against a version comparison. + */ +import type { OrcadActivationRecord } from './orcad-activation-record' + +export type OrcadTerminalCensus = { + /** + * Sessions the live daemon owns right now. `null` means the probe could not answer — + * never treated as zero, because loss of contact is not evidence of process death + * (docs/reference/ssh-execution-boundary.md). + */ + liveSessions: number | null + /** + * Of those, how many started at or after `record.activatedAt`. These are the sessions the + * pre-activation snapshot does not describe. + */ + startedSinceActivation: number | null +} + +export type OrcadUpdateDecision = + | { action: 'noop'; reason: string } + | { + action: 'proceed' + /** True when a live daemon will be carried across the restart rather than replaced. */ + preservesLiveDaemon: boolean + notes: string[] + } + | { action: 'defer'; code: OrcadUpdateDeferCode; reason: string } + +export type OrcadUpdateDeferCode = + | 'orcad_update_terminals_running' + | 'orcad_update_terminal_census_unavailable' + +/** + * Decide whether to restart orcad onto `candidateVersion`. + * + * Deferring on live terminals is a deliberate choice, not caution. The restart itself is + * non-destructive, but it leaves the host running a NEW orcad against an OLD daemon until + * every one of those terminals exits — a mixed pair whose duration the operator, not the + * deploy, should decide. `force` is how they decide it. + */ +export function planOrcadUpdate(input: { + record: OrcadActivationRecord + candidateVersion: string + census: OrcadTerminalCensus + force?: boolean +}): OrcadUpdateDecision { + if (input.record.active === input.candidateVersion) { + return { + action: 'noop', + reason: `${input.candidateVersion} is already the active version; nothing to restart.` + } + } + const { liveSessions } = input.census + if (liveSessions === null) { + if (!input.force) { + return { + action: 'defer', + code: 'orcad_update_terminal_census_unavailable', + reason: + 'The terminal daemon did not answer a session count, so this update cannot tell ' + + 'whether work is running on the host. Retry, or force the update knowing terminals ' + + 'may be mid-flight.' + } + } + return { + action: 'proceed', + // Why true: an unverifiable census must be planned for as if sessions exist. Assuming + // the daemon is replaceable is the assumption that destroys terminals. + preservesLiveDaemon: true, + notes: [ + 'Forced with an unverifiable session count. Planning as if terminals are live: the ' + + 'daemon will be preserved across the restart, and the outgoing version directory ' + + 'stays pinned against GC.' + ] + } + } + if (liveSessions > 0 && !input.force) { + return { + action: 'defer', + code: 'orcad_update_terminals_running', + reason: + `${liveSessions} terminal${liveSessions === 1 ? ' is' : 's are'} running on this host. ` + + 'The restart would not kill them — the daemon is preserved — but the host would run ' + + `orcad ${input.candidateVersion} against a daemon forked from ` + + `${input.record.active ?? 'the previous build'} until they all exit. Update when the ` + + 'host is idle, or force it.' + } + } + if (liveSessions > 0) { + return { + action: 'proceed', + preservesLiveDaemon: true, + notes: [ + `Forced with ${liveSessions} live terminal${liveSessions === 1 ? '' : 's'}. They survive ` + + 'the restart on the existing daemon; the outgoing version directory stays pinned ' + + 'against GC because that daemon was forked from it.' + ] + } + } + return { + action: 'proceed', + // Zero live sessions is the one case where daemon-init's freshness branch replaces the + // daemon, so nothing is carried across and nothing is lost. + preservesLiveDaemon: false, + notes: [ + 'No terminals are running, so the daemon is replaced by one forked from the new bundle.' + ] + } +} + +export type OrcadRollbackSafety = + | { safety: 'clean'; target: string; notes: string[] } + | { safety: 'lossy'; target: string; discards: string[] } + | { safety: 'unsafe'; code: OrcadRollbackUnsafeCode; reason: string } + +export type OrcadRollbackUnsafeCode = + | 'orcad_rollback_no_target' + | 'orcad_rollback_snapshot_missing' + | 'orcad_rollback_orphans_live_terminals' + | 'orcad_rollback_census_unavailable' + +/** + * How safe it is to switch back to `record.previous`. + * + * **The point past which rollback is unsafe is the first terminal created after + * activation.** Not the first state write, and not any schema comparison: + * + * - Rolling back means restoring the pre-activation snapshot, because there is no schema + * version to prove the old build can read what the new one wrote. + * - The snapshot predates activation, so it does not describe sessions created since. + * - The daemon survives the binary swap and still owns those sessions. After the restore, + * a live daemon holds PTYs that the restored store has no rows for: work that is running, + * that no client can reattach to, and that the host will report as neither `live` nor + * `exited` for any session anyone can name. + * + * Settings and UI churn written after activation are merely discarded, which is `lossy`. + * Orphaning running work is not something a deploy gets to do quietly, so it is `unsafe`. + */ +export function assessOrcadRollback(input: { + record: OrcadActivationRecord + /** Whether the snapshot named by the record is actually still on the host. */ + snapshotPresent: boolean + census: OrcadTerminalCensus + /** + * Whether the shared store has been written since activation, from its mtime against + * `record.activatedAt`. `null` means unknown, which is treated as "yes" — claiming a + * lossless rollback we cannot demonstrate is the failure mode, not the caution. + */ + stateWritesSinceActivation: boolean | null +}): OrcadRollbackSafety { + const target = input.record.previous + if (!target) { + return { + safety: 'unsafe', + code: 'orcad_rollback_no_target', + reason: + 'This host has no previous orcad version recorded, so there is nothing to roll back ' + + 'to. Deploy a known-good build instead.' + } + } + if (!input.record.snapshot || !input.snapshotPresent) { + return { + safety: 'unsafe', + code: 'orcad_rollback_snapshot_missing', + reason: + `The pre-activation state snapshot for ${input.record.active ?? 'the active version'} ` + + 'is gone, and Orca state carries no schema version that could prove the older build ' + + 'can read what the newer one migrated. Switching the binary back would hand ' + + `${target} a store it may not understand. Deploy forward instead.` + } + } + const { startedSinceActivation } = input.census + if (startedSinceActivation === null) { + return { + safety: 'unsafe', + code: 'orcad_rollback_census_unavailable', + reason: + 'The daemon did not answer how many of its terminals started after this version was ' + + 'activated, so a snapshot restore might orphan running work. Retry when the host is ' + + 'reachable.' + } + } + if (startedSinceActivation > 0) { + return { + safety: 'unsafe', + code: 'orcad_rollback_orphans_live_terminals', + reason: + `${startedSinceActivation} terminal${startedSinceActivation === 1 ? '' : 's'} started ` + + 'after this version was activated. The daemon survives the rollback and would keep ' + + 'owning them, but the restored snapshot predates them, so nothing would be able to ' + + 'reattach. Close them (or let them exit) and roll back then.' + } + } + if (input.stateWritesSinceActivation === false) { + return { + safety: 'clean', + target, + notes: [ + 'The pre-activation snapshot is intact, no terminals started since activation, and ' + + 'the store has not been written since. Restoring it changes nothing.' + ] + } + } + return { + safety: 'lossy', + target, + discards: [ + input.stateWritesSinceActivation === null + ? 'Any profile, settings and UI change written since activation — the store mtime ' + + 'could not be read, so assume there are some.' + : `Every profile, settings and UI change written since ${ + input.record.activatedAt ?? 'activation' + }, when ${input.record.active ?? 'the active version'} was activated.` + ] + } +} diff --git a/src/main/ssh/remote-install-coexistence.test.ts b/src/main/ssh/remote-install-coexistence.test.ts new file mode 100644 index 00000000000..ce844587d62 --- /dev/null +++ b/src/main/ssh/remote-install-coexistence.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import { selectRemoteInstallModel } from './remote-install-coexistence' + +const BOTH_INSTALLED = ['relay-0.1.0+aa01', 'orcad-0.2.0+bb01', 'orcad-0.1.0+aa01'] + +describe('what a client does when it finds both models installed', () => { + it('uses the registered model and leaves the other install alone', () => { + const selection = selectRemoteInstallModel({ + registration: 'orcad-peer', + installedDirNames: BOTH_INSTALLED + }) + expect(selection).toMatchObject({ outcome: 'use', model: 'orcad' }) + expect(selection.outcome === 'use' && selection.coexisting).toEqual(['relay-0.1.0+aa01']) + expect(selection.outcome === 'use' && selection.note).toContain( + 'garbage-collects only its own namespace' + ) + }) + + it('does not switch model just because the other one is also on disk', () => { + const selection = selectRemoteInstallModel({ + registration: 'ssh-target', + installedDirNames: BOTH_INSTALLED + }) + expect(selection).toMatchObject({ outcome: 'use', model: 'relay' }) + expect(selection.outcome === 'use' && selection.coexisting).toEqual([ + 'orcad-0.2.0+bb01', + 'orcad-0.1.0+aa01' + ]) + }) + + it('picks the registered model even when only the other one is installed', () => { + // On-disk presence is diagnostic, never a vote: an orcad-registered host with only relay + // dirs is a first orcad deploy, not a reason to fall back to the relay. + const selection = selectRemoteInstallModel({ + registration: 'orcad-peer', + installedDirNames: ['relay-0.1.0+aa01'] + }) + expect(selection).toMatchObject({ outcome: 'use', model: 'orcad' }) + }) + + it('refuses a machine registered under both models', () => { + const selection = selectRemoteInstallModel({ + registration: 'both', + installedDirNames: BOTH_INSTALLED + }) + expect(selection).toMatchObject({ + outcome: 'refuse', + code: 'remote_host_registered_under_both_models' + }) + // The forbidden thing is two registrations, not two directories — say so, or a user will + // "fix" it by deleting an install that is serving someone. + expect(selection.outcome === 'refuse' && selection.reason).toContain( + 'Leaving both install directories on disk is fine' + ) + }) + + it('refuses to infer a model for an unregistered machine', () => { + const selection = selectRemoteInstallModel({ + registration: 'none', + installedDirNames: BOTH_INSTALLED + }) + expect(selection).toMatchObject({ + outcome: 'refuse', + code: 'remote_host_not_registered' + }) + }) + + it('says nothing when there is nothing coexisting', () => { + const selection = selectRemoteInstallModel({ + registration: 'orcad-peer', + installedDirNames: ['orcad-0.2.0+bb01'] + }) + expect(selection).toMatchObject({ outcome: 'use', model: 'orcad', note: null }) + }) +}) diff --git a/src/main/ssh/remote-install-coexistence.ts b/src/main/ssh/remote-install-coexistence.ts new file mode 100644 index 00000000000..a24ac36cfb1 --- /dev/null +++ b/src/main/ssh/remote-install-coexistence.ts @@ -0,0 +1,83 @@ +/** + * What a client does when it finds both a relay and an orcad installed on one host. + * + * `docs/design/shipping-orcad.html` §06 draws the line the boundary doc actually draws: + * two *directories* on disk are fine and permanent; two *registered targets* for one + * machine are forbidden, because that is what splits a machine's worktrees across two + * identities (`docs/reference/ssh-execution-boundary.md`). + * + * So the model is never inferred from the filesystem. It is decided by how the user + * registered the host, and the on-disk inventory is only ever diagnostic. Inferring it — + * "an orcad dir exists, so prefer orcad" — would let a GC pass, a half-finished install or + * a stale tree silently re-point a live connection at a different execution identity. + */ +import { inventoryRemoteInstallDirs, type RemoteInstallModelId } from './remote-install-model' + +/** + * How this host is registered in the client's own records. + * + * `both` is representable on purpose: it is a state a user can reach by adding an SSH + * target for a machine they have already paired, and the point of this module is to refuse + * it loudly instead of picking one. + */ +export type RemoteHostRegistration = 'ssh-target' | 'orcad-peer' | 'both' | 'none' + +export type RemoteInstallSelection = + | { + outcome: 'use' + model: RemoteInstallModelId + /** Other-model dirs present on this host. Left alone; never GC'd by the chosen model. */ + coexisting: string[] + /** Non-null when there is something an operator should know but nothing to refuse over. */ + note: string | null + } + | { + outcome: 'refuse' + code: 'remote_host_registered_under_both_models' | 'remote_host_not_registered' + reason: string + } + +/** + * Choose the execution model for a connection to a host, given its registration and + * whatever happens to be installed there. + */ +export function selectRemoteInstallModel(input: { + registration: RemoteHostRegistration + /** Raw directory names under `~/.orca-remote/`, as listed on the host. */ + installedDirNames: readonly string[] +}): RemoteInstallSelection { + if (input.registration === 'both') { + return { + outcome: 'refuse', + code: 'remote_host_registered_under_both_models', + reason: + 'This machine is registered both as an SSH target and as a paired orcad peer. One ' + + 'machine must have one execution identity, or its worktrees and terminals split ' + + 'across two owners that cannot see each other. Remove one registration. Leaving ' + + 'both install directories on disk is fine and expected.' + } + } + if (input.registration === 'none') { + return { + outcome: 'refuse', + code: 'remote_host_not_registered', + reason: + 'This machine has no execution model registered. Add it as an SSH target or pair it ' + + 'as an orcad peer; what is already installed on it does not decide which it is.' + } + } + const model: RemoteInstallModelId = input.registration === 'ssh-target' ? 'relay' : 'orcad' + const inventory = inventoryRemoteInstallDirs(input.installedDirNames) + const coexisting = model === 'relay' ? inventory.orcad : inventory.relay + return { + outcome: 'use', + model, + coexisting, + note: + coexisting.length > 0 + ? `This host also has ${coexisting.length} ${model === 'relay' ? 'orcad' : 'relay'} ` + + `install directory(ies) (${coexisting.join(', ')}). They are left untouched: each ` + + 'model garbage-collects only its own namespace.' + : null + } +} diff --git a/src/main/ssh/remote-install-gc.ts b/src/main/ssh/remote-install-gc.ts new file mode 100644 index 00000000000..0d649cd0b03 --- /dev/null +++ b/src/main/ssh/remote-install-gc.ts @@ -0,0 +1,286 @@ +/** + * The version-directory garbage collector, shared by the relay and orcad. + * + * It lives apart from `ssh-relay-versioned-install.ts` because it is the one piece both + * models run, and because the ownership rule below is the whole point of separating them: + * a pass only ever sees, and only ever deletes, directories belonging to `model`. + */ +import type { SshConnection } from './ssh-connection' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { execCommand } from './ssh-relay-deploy-helpers' +import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' +import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { + RELAY_INSTALL_MODEL, + remoteInstallGcPermits, + remoteInstallVersionDirRegex, + type RemoteInstallModel +} from './remote-install-model' +import { + isRelayGcClaimOwned, + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim +} from './ssh-relay-gc-claim' +import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' +import { + listRemoteInstallBaseDirsCommand, + MAX_RELAY_GC_LISTING_ENTRIES, + moveRemoteTreeCommand, + probeFileExistsCommand, + relayLivenessProbeCommand, + removeRemoteTreeCommand +} from './ssh-remote-commands' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + remoteBasename, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' + +// Legacy relay dirs predate `.install-complete`; they need a liveness-only GC check so they +// eventually drain. There is no orcad equivalent — orcad has never shipped without one. +const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/ +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string +): Promise { + return execCommand(conn, command, { wrapCommand: host.commandDialect !== 'powershell' }) +} + +export type RemoteInstallGcOptions = { + windowsNodePath?: string + windowsSockNames?: string[] + /** + * Model-specific "someone is using this directory" probe. It must answer TRUE when + * inconclusive — an unanswered probe is never evidence a tree is idle. + */ + isDirLive: (dir: string) => Promise + /** + * Directories this pass must never remove even when idle and complete, named by directory + * (not absolute path). orcad passes its active and previous versions: the previous one is + * the rollback target, and GC'ing it turns a recoverable bad update into a re-deploy. + */ + pinnedDirNames?: readonly string[] +} + +/** + * Garbage-collect one model's old version directories. + * + * **GC ownership (design §06 falsifier 1):** a pass only ever sees, and only ever deletes, + * directories belonging to `model`. The remote listing is scoped by prefix, and + * `remoteInstallGcPermits` re-checks every candidate locally, so neither a widened glob nor + * a hand-rolled listing can make one model delete the other's live install. + */ +export async function gcOldRemoteInstallVersions( + conn: SshConnection, + model: RemoteInstallModel, + remoteHome: string, + currentDirAbsPath: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options: RemoteInstallGcOptions +): Promise { + const baseDir = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) + const currentDirName = remoteBasename(currentDirAbsPath, host) + let listing: string + try { + listing = await execHostCommand( + conn, + host, + listRemoteInstallBaseDirsCommand(host, baseDir, model) + ) + } catch { + return + } + const entries = listing + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + .slice(0, MAX_RELAY_GC_LISTING_ENTRIES) + + await cleanupRelayGcTombstones(conn, baseDir, entries, host) + + const versionDirRegex = remoteInstallVersionDirRegex(model) + const pinned = new Set([currentDirName, ...(options.pinnedDirNames ?? [])]) + const candidates = entries + // Why re-check ownership after a prefix-scoped listing: this is the one line that stands + // between a parameterized GC and deleting the sibling model's live install. + .filter((name) => remoteInstallGcPermits(model, name)) + .filter((name) => versionDirRegex.test(name)) + .filter((name) => !pinned.has(name)) + + if (candidates.length === 0) { + return + } + + const removed: string[] = [] + const kept: string[] = [] + for (const name of candidates) { + const dir = joinRemotePath(host, baseDir, name) + try { + const safe = await isCandidateSafeToRemove(conn, model, dir, name, host, options) + if (!safe) { + kept.push(name) + continue + } + // Why: the claim is a sibling, so it survives moving/deleting the candidate and lets installers back out first. + const gcClaimToken = await tryAcquireRelayGcClaim(conn, dir, host) + if (!gcClaimToken) { + kept.push(name) + continue + } + let preserveGcClaim = false + let gcClaimReleaseNeeded = true + try { + // Recheck under the stable claim; installers probe it before and after creating their lock, closing both orders. + if (!(await isCandidateSafeToRemove(conn, model, dir, name, host, options))) { + kept.push(name) + continue + } + if (!(await isRelayGcClaimOwned(conn, dir, gcClaimToken, host))) { + kept.push(name) + continue + } + const tombstone = `${dir}.gc-tombstone.${process.pid}.${Date.now()}` + const moved = await execHostCommand(conn, host, moveRemoteTreeCommand(host, dir, tombstone)) + if (moved.trim() !== 'MOVED') { + kept.push(name) + continue + } + // Once renamed, a fresh install at the original path is isolated from the tombstone's deletion, so release the claim. + const release = await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) + gcClaimReleaseNeeded = release === 'unknown' + await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + preserveGcClaim = true + } + throw err + } finally { + if (!preserveGcClaim && gcClaimReleaseNeeded) { + await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) + } + } + removed.push(name) + } catch (err) { + console.warn( + `[${model.id}] GC failed for ${dir}: ${err instanceof Error ? err.message : String(err)}` + ) + kept.push(name) + } + } + + if (removed.length > 0) { + const keptSuffix = kept.length > 0 ? ` (kept: ${kept.join(', ')})` : '' + console.log( + `[${model.id}] GC: removed ${removed.length} stale version dir(s): ${removed.join(', ')}${keptSuffix}` + ) + } +} + +async function isCandidateSafeToRemove( + conn: SshConnection, + model: RemoteInstallModel, + dir: string, + name: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options: RemoteInstallGcOptions +): Promise { + const isLegacy = model.id === 'relay' && LEGACY_RELAY_DIR_REGEX.test(name) + + const lockDir = joinRemotePath(host, dir, RELAY_INSTALL_LOCK_NAME) + let lockProbe: string + try { + lockProbe = await execHostCommand(conn, host, probeInstallLockExistsCommand(host, lockDir)) + } catch { + return false + } + const lockState = lockProbe.trim() + if (lockState !== 'OPEN' && lockState !== 'LOCKED') { + return false + } + const locked = lockState === 'LOCKED' + + if (locked) { + // Why: stale lock = crashed installer; finalize can leave a dir .install-complete yet locked (lock-rm failed), so it's reclaimable. + if (!(await isRelayInstallLockStale(conn, lockDir, host))) { + return false + } + process.stderr.write?.(`[${model.id}] GC: lock at ${lockDir} is stale; treating as recoverable\n`) + } + + // Legacy dirs predate .install-complete; skip the sentinel and rely on the live-socket probe alone. + if (!isLegacy) { + const completePath = joinRemotePath(host, dir, model.installCompleteFilename) + const completeProbe = await execHostCommand( + conn, + host, + probeFileExistsCommand(host, completePath) + ).catch(() => 'PARTIAL') + if (completeProbe.trim() !== 'COMPLETE') { + // Crashed-install partial; leave for the next deploy to recover. + return false + } + } + + return !(await options.isDirLive(dir)) +} + + +/** + * The relay's GC, bound to its own namespace and its own liveness probe (a live unix socket + * or Windows pipe inside the version dir). + */ +export async function gcOldRelayVersions( + conn: SshConnection, + remoteHome: string, + currentDirAbsPath: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { + windowsNodePath?: string + windowsSockNames?: string[] + } +): Promise { + await gcOldRemoteInstallVersions(conn, RELAY_INSTALL_MODEL, remoteHome, currentDirAbsPath, host, { + ...options, + isDirLive: (dir) => hasLiveRelaySocket(conn, dir, host, options) + }) +} + +async function hasLiveRelaySocket( + conn: SshConnection, + dir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { + windowsNodePath?: string + windowsSockNames?: string[] + } +): Promise { + try { + // Why: `test -S` only — a connect-and-close probe would race with a daemon about to idle. + const windowsOptions = + isWindowsRemoteHost(host) && options?.windowsNodePath + ? { + nodePath: options.windowsNodePath, + pipePaths: (options.windowsSockNames ?? []).flatMap((sockName) => + windowsRelayPipePathsForSocketName(host, dir, sockName) + ) + } + : undefined + const out = await execHostCommand( + conn, + host, + relayLivenessProbeCommand(host, dir, windowsOptions) + ) + const state = out.trim() + return state !== 'DEAD' && state !== 'WAITING' + } catch { + // Why: an inconclusive liveness probe must never authorize deletion. + return true + } +} diff --git a/src/main/ssh/remote-install-model.test.ts b/src/main/ssh/remote-install-model.test.ts new file mode 100644 index 00000000000..5a80df8ff4f --- /dev/null +++ b/src/main/ssh/remote-install-model.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' + +import { + inventoryRemoteInstallDirs, + ORCAD_INSTALL_MODEL, + RELAY_INSTALL_MODEL, + remoteInstallDirName, + remoteInstallDirOwner, + remoteInstallGcPermits, + remoteInstallListingRegexSource, + remoteInstallVersionDirRegex +} from './remote-install-model' + +const RELAY_DIRS = ['relay-0.1.0+abcdef123456', 'relay-v0.1.0', 'relay-1.2.3'] +const ORCAD_DIRS = ['orcad-0.1.0+abcdef123456', 'orcad-v0.1.0', 'orcad-1.2.3'] + +describe('remote install namespace', () => { + it('names each model its own version dir', () => { + expect(remoteInstallDirName(RELAY_INSTALL_MODEL, '0.1.0+aa')).toBe('relay-0.1.0+aa') + expect(remoteInstallDirName(ORCAD_INSTALL_MODEL, '0.1.0+aa')).toBe('orcad-0.1.0+aa') + }) + + it('keeps the relay listing pattern byte-identical to the one it shipped with', () => { + // The literal that was hardcoded in `listRelayBaseDirsCommand` before it was + // parameterized. A drift here changes what an existing host's GC can see. + expect(remoteInstallListingRegexSource(RELAY_INSTALL_MODEL)).toBe( + String.raw`^relay-(v?[0-9]+\.[0-9]+\.[0-9]+(\+[0-9a-f]+)?)(\.gc-tombstone\.[0-9]+\.[0-9]+)?$` + ) + }) + + it('refuses a dir prefix that could escape a remote glob or quote', () => { + const injected = { ...RELAY_INSTALL_MODEL, dirPrefix: "relay'; rm -rf ~" } + expect(() => remoteInstallVersionDirRegex(injected)).toThrow('Unsafe remote install dir prefix') + }) +}) + +describe('GC ownership — each model collects only its own namespace', () => { + it.each(ORCAD_DIRS)('the relay never permits GC of %s', (dirName) => { + expect(remoteInstallDirOwner(dirName)).toBe('orcad') + expect(remoteInstallGcPermits(RELAY_INSTALL_MODEL, dirName)).toBe(false) + }) + + it.each(RELAY_DIRS)('orcad never permits GC of %s', (dirName) => { + expect(remoteInstallDirOwner(dirName)).toBe('relay') + expect(remoteInstallGcPermits(ORCAD_INSTALL_MODEL, dirName)).toBe(false) + }) + + it('permits each model its own dirs and its own tombstones', () => { + expect(remoteInstallGcPermits(RELAY_INSTALL_MODEL, 'relay-0.1.0+aa')).toBe(true) + expect(remoteInstallGcPermits(ORCAD_INSTALL_MODEL, 'orcad-0.1.0+aa')).toBe(true) + expect(remoteInstallGcPermits(ORCAD_INSTALL_MODEL, 'orcad-0.1.0+aa.gc-tombstone.12.34')).toBe( + true + ) + }) + + it('claims nothing it did not create', () => { + for (const name of ['.orca-remote', 'orcad', 'relayish-0.1.0', 'orcad-notaversion', 'node']) { + expect(remoteInstallDirOwner(name)).toBeNull() + expect(remoteInstallGcPermits(RELAY_INSTALL_MODEL, name)).toBe(false) + expect(remoteInstallGcPermits(ORCAD_INSTALL_MODEL, name)).toBe(false) + } + }) + + it('groups a mixed listing without losing anything to the wrong owner', () => { + const inventory = inventoryRemoteInstallDirs([...RELAY_DIRS, ...ORCAD_DIRS, 'something-else']) + expect(inventory.relay).toEqual(RELAY_DIRS) + expect(inventory.orcad).toEqual(ORCAD_DIRS) + expect(inventory.unknown).toEqual(['something-else']) + }) +}) diff --git a/src/main/ssh/remote-install-model.ts b/src/main/ssh/remote-install-model.ts new file mode 100644 index 00000000000..54be330a55f --- /dev/null +++ b/src/main/ssh/remote-install-model.ts @@ -0,0 +1,143 @@ +/** + * The two things Orca installs into `~/.orca-remote/`, and the rules that keep them from + * touching each other. + * + * `docs/design/shipping-orcad.html` §06 settles that on-disk coexistence is permanent: the + * relay is the dumb execution host for SSH-target users, orcad is the peer for paired + * environments, and no plan item retires either. So `relay-/` and `orcad-/` + * sit side by side forever, and the namespace has to be a parameter rather than a literal. + * + * GC ownership is the trap that parameterization creates. Each model garbage-collects ONLY + * its own directories — see `remoteInstallDirOwner`. Relay's regex happened to be narrow + * enough already; making the prefix a parameter is exactly what could have widened it into + * deleting a live orcad tree, so the ownership rule is asserted here rather than left to + * whichever regex a caller passes. + */ +import { + relayArtifactFilenames, + RELAY_INSTALL_COMPLETE_FILENAME, + RELAY_VERSION_FILENAME +} from '../../shared/relay-artifacts' +import { + orcadArtifactFilenames, + ORCAD_INSTALL_COMPLETE_FILENAME, + ORCAD_VERSION_FILENAME +} from '../../shared/orcad-artifacts' + +export type RemoteInstallModelId = 'relay' | 'orcad' + +export type RemoteInstallModel = { + readonly id: RemoteInstallModelId + /** Leading segment of every version dir: `-`. */ + readonly dirPrefix: string + /** npm package name written into the remote `package.json` for native deps. */ + readonly nativeDepsPackageName: string + readonly versionFilename: string + readonly installCompleteFilename: string + /** Files whose absence means a torn install, so the probe forces a re-deploy. */ + requiredArtifacts(isWindows: boolean): string[] +} + +export const RELAY_INSTALL_MODEL: RemoteInstallModel = { + id: 'relay', + dirPrefix: 'relay', + nativeDepsPackageName: 'orca-relay', + versionFilename: RELAY_VERSION_FILENAME, + installCompleteFilename: RELAY_INSTALL_COMPLETE_FILENAME, + requiredArtifacts: (isWindows) => relayArtifactFilenames(isWindows) +} + +export const ORCAD_INSTALL_MODEL: RemoteInstallModel = { + id: 'orcad', + dirPrefix: 'orcad', + nativeDepsPackageName: 'orca-orcad', + versionFilename: ORCAD_VERSION_FILENAME, + installCompleteFilename: ORCAD_INSTALL_COMPLETE_FILENAME, + // Why the parameter is ignored: orcad's forked children are the same three .js files on + // every host. The Windows-only console-list agent patch is a relay/node-pty concern. + requiredArtifacts: () => orcadArtifactFilenames() +} + +export const REMOTE_INSTALL_MODELS: readonly RemoteInstallModel[] = [ + RELAY_INSTALL_MODEL, + ORCAD_INSTALL_MODEL +] + +/** + * The version half of a directory name, shared by both models. + * + * Why `[0-9]` and not `\d`: this exact source string is also embedded in an awk ERE and a + * PowerShell `-match` on the remote host. Those three dialects agree on `[0-9]`, `\.` and + * `\+`; only JavaScript understands `\d`. + */ +const VERSION_PATTERN = String.raw`v?[0-9]+\.[0-9]+\.[0-9]+(\+[0-9a-f]+)?` + +/** Suffix GC leaves behind mid-delete; the listing must surface these so they can be swept. */ +const TOMBSTONE_PATTERN = String.raw`\.gc-tombstone\.[0-9]+\.[0-9]+` + +/** + * Why validated and not merely typed: the prefix is interpolated into a remote `find -name` + * glob, an awk regex and a single-quoted PowerShell literal. A quote or a metacharacter + * here would be a remote-shell injection on the client's own connection. + */ +function assertSafeDirPrefix(dirPrefix: string): void { + if (!/^[a-z][a-z0-9-]*$/.test(dirPrefix)) { + throw new Error(`Unsafe remote install dir prefix: ${JSON.stringify(dirPrefix)}`) + } +} + +export function remoteInstallDirName(model: RemoteInstallModel, fullVersion: string): string { + assertSafeDirPrefix(model.dirPrefix) + return `${model.dirPrefix}-${fullVersion}` +} + +/** Matches a live version dir for exactly one model — never a tombstone, never a sibling model. */ +export function remoteInstallVersionDirRegex(model: RemoteInstallModel): RegExp { + assertSafeDirPrefix(model.dirPrefix) + return new RegExp(`^${model.dirPrefix}-(${VERSION_PATTERN})$`) +} + +/** What the remote listing is allowed to return: live dirs plus their tombstones. */ +export function remoteInstallListingRegexSource(model: RemoteInstallModel): string { + assertSafeDirPrefix(model.dirPrefix) + return `^${model.dirPrefix}-(${VERSION_PATTERN})(${TOMBSTONE_PATTERN})?$` +} + +/** + * Which model owns a directory found in `~/.orca-remote/`, or null for anything neither + * model created. + * + * This is the answer to §06 falsifier 1's first half: **the model that created a directory + * owns it, and nothing else may delete it.** A relay GC pass that saw `orcad-0.1.0+abc` + * would be looking at the live install of a peer whose lifecycle it has no view into — the + * SSH-execution-boundary collapse in directory form. + */ +export function remoteInstallDirOwner(dirName: string): RemoteInstallModelId | null { + for (const model of REMOTE_INSTALL_MODELS) { + if (new RegExp(remoteInstallListingRegexSource(model)).test(dirName)) { + return model.id + } + } + return null +} + +/** True when `model` is allowed to garbage-collect `dirName`. */ +export function remoteInstallGcPermits(model: RemoteInstallModel, dirName: string): boolean { + return remoteInstallDirOwner(dirName) === model.id +} + +export type RemoteInstallInventory = Record + +/** Group a raw `~/.orca-remote/` listing by owning model, for diagnostics and the client's choice. */ +export function inventoryRemoteInstallDirs(dirNames: readonly string[]): RemoteInstallInventory { + const inventory: RemoteInstallInventory = { relay: [], orcad: [], unknown: [] } + for (const name of dirNames) { + const owner = remoteInstallDirOwner(name) + if (owner) { + inventory[owner].push(name) + } else { + inventory.unknown.push(name) + } + } + return inventory +} diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts index 608fca5c6eb..bcb64d3bdf9 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.ts @@ -1,164 +1,24 @@ import { execCommand } from './ssh-relay-deploy-helpers' import type { SshConnection } from './ssh-connection' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' +import { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + type BuildToolchainStatus +} from './build-toolchain-diagnosis' -// Why: node-pty@1.1.0 ships no Linux prebuild, so the remote `npm install` falls -// back to `node-gyp rebuild` and needs a C/C++ toolchain. A missing toolchain is -// the dominant first-connect failure on Linux relays (#1693); node-gyp surfaces -// it as an opaque `not found: make`. We probe for the tools so we can replace -// that with an actionable "install build-essential" message. -const PROBED_TOOLS = [ - 'make', - 'gcc', - 'g++', - 'cc', - 'c++', - 'clang', - 'clang++', - 'python3', - 'python' -] as const - -// Package managers mapped to the one-liner that installs a C/C++ toolchain on -// the matching distro family. Ordered by detection priority. -const PACKAGE_MANAGER_HINTS: readonly { bin: string; install: string }[] = [ - { bin: 'apt-get', install: 'sudo apt-get install -y build-essential python3' }, - { bin: 'dnf', install: 'sudo dnf install -y make gcc gcc-c++ python3' }, - { bin: 'yum', install: 'sudo yum install -y make gcc gcc-c++ python3' }, - { bin: 'pacman', install: 'sudo pacman -S --needed base-devel python' }, - { bin: 'apk', install: 'sudo apk add build-base python3' }, - { bin: 'zypper', install: 'sudo zypper install -y gcc gcc-c++ make python3' } -] - -export type BuildToolchainStatus = { - present: string[] - packageManager: string | null - // node-gyp needs make, Python, and a C++ compiler. The caller only uses this - // verdict after npm/node-gyp output already points at a native-build failure, - // so custom Python paths do not make unrelated npm failures look toolchainy. - toolchainMissing: boolean -} - -function hasCxxCompiler(present: ReadonlySet): boolean { - return present.has('g++') || present.has('c++') || present.has('clang++') -} - -function hasPython(present: ReadonlySet): boolean { - return present.has('python3') || present.has('python') -} - -// POSIX-sh probe: echo a `HAVE ` line per resolvable build tool and a -// single `PKG ` line for the host's package manager. Runs under -// `/bin/sh -c` (see wrapRemoteCommandForPosixShell), so it stays portable. -export function buildToolchainProbeCommand(): string { - const toolLoop = `for t in ${PROBED_TOOLS.join( - ' ' - )}; do if command -v "$t" >/dev/null 2>&1; then echo "HAVE $t"; fi; done` - const pkgList = PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ') - const pkgLoop = `for p in ${pkgList}; do if command -v "$p" >/dev/null 2>&1; then echo "PKG $p"; break; fi; done` - return `${toolLoop}; ${pkgLoop}` -} - -export function parseBuildToolchainProbe(output: string): BuildToolchainStatus { - const present = new Set() - let packageManager: string | null = null - for (const line of output.split('\n')) { - const haveMatch = line.trim().match(/^HAVE (\S+)$/) - if (haveMatch) { - present.add(haveMatch[1]) - continue - } - const pkgMatch = line.trim().match(/^PKG (\S+)$/) - if (pkgMatch && !packageManager) { - packageManager = pkgMatch[1] - } - } - return { - present: PROBED_TOOLS.filter((tool) => present.has(tool)), - packageManager, - toolchainMissing: !present.has('make') || !hasCxxCompiler(present) || !hasPython(present) - } -} - -export function shouldProbeBuildToolchainAfterNativeDepsFailure(message: string): boolean { - const lower = message.toLowerCase() - if (!lower.includes('gyp') && !lower.includes('node-gyp')) { - return false - } - return ( - /\bnot found:\s*(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b/i.test( - message - ) || - /\b(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b.*\bnot found\b/i.test( - message - ) || - lower.includes('could not find any python installation') || - lower.includes('no xcode or clt version detected') - ) -} - -function missingToolNames(status: BuildToolchainStatus): string[] { - const present = new Set(status.present) - const missing: string[] = [] - if (!present.has('make')) { - missing.push('make') - } - if (!hasCxxCompiler(present)) { - missing.push('a C++ compiler (g++ or clang++)') - } - if (!hasPython(present)) { - missing.push('python3') - } - return missing -} - -/** Install hint for the host's package manager, or the cross-distro list when it is unknown. */ -export function toolchainInstallHintLines(status: BuildToolchainStatus): string[] { - const tailored = status.packageManager - ? PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === status.packageManager)?.install - : null - if (tailored) { - return [` ${tailored}`] - } - return [ - ' Debian/Ubuntu: sudo apt-get install -y build-essential python3', - ' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3', - ' Arch: sudo pacman -S --needed base-devel python', - ' Alpine: sudo apk add build-base python3' - ] -} - -/** One-line summary for the deploy log when node-pty is skipped rather than compiled. */ -export function formatSkippedNodePtyWarning(status: BuildToolchainStatus): string { - // Why: with no package manager detected the hint list is the cross-distro menu, whose first line - // is Debian's — quoting it alone would name the wrong distro, so stay neutral instead. - const hintLines = toolchainInstallHintLines(status) - const hint = - hintLines.length === 1 - ? hintLines[0].trim() - : 'install a C/C++ toolchain (make, a C++ compiler, python3)' - return ( - `missing build tools (${missingToolNames(status).join(', ')}); skipping node-pty so the ` + - `connection still serves files and git. Remote terminals need: ${hint}` - ) -} - -export function formatMissingToolchainError( - status: BuildToolchainStatus, - underlyingError: string -): string { - const lines = [ - `The remote host is missing the C/C++ build tools (${missingToolNames(status).join(', ')}) ` + - `needed to compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` + - `prebuilt binary for Linux, so they must be compiled on the remote host.`, - '', - 'Install the build tools on the remote host, then reconnect:', - ...toolchainInstallHintLines(status), - '', - `Underlying install error: ${underlyingError}` - ] - return lines.join('\n') -} +// Why re-exported rather than moved outright: `ssh-relay-deploy.ts` and the relay tests +// import the whole diagnosis surface from here, and the split exists for bundle reasons, +// not to redraw the relay's own API. +export { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + shouldProbeBuildToolchainAfterNativeDepsFailure, + toolchainInstallHintLines, + formatSkippedNodePtyWarning, + formatMissingToolchainError +} from './build-toolchain-diagnosis' +export type { BuildToolchainStatus } from './build-toolchain-diagnosis' // Best-effort: returns null on Windows hosts (node-pty ships win32 prebuilds, so // a missing toolchain isn't the failure there) or if the probe itself errors — diff --git a/src/main/ssh/ssh-relay-install-namespace.ts b/src/main/ssh/ssh-relay-install-namespace.ts index a75b8048841..6fd3c2aa89f 100644 --- a/src/main/ssh/ssh-relay-install-namespace.ts +++ b/src/main/ssh/ssh-relay-install-namespace.ts @@ -7,6 +7,11 @@ // See: docs/ssh-relay-sftp-namespace.md import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + RELAY_INSTALL_MODEL, + remoteInstallDirName, + type RemoteInstallModel +} from './remote-install-model' import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' import { shellEscape } from './ssh-connection-utils' import { RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' @@ -37,7 +42,19 @@ export function relayRemoteDirSegments( fullVersion: string, pathFlavor: RemotePathFlavor ): string[] { - const segments = [RELAY_REMOTE_DIR, `relay-${fullVersion}`] + return remoteInstallDirSegments(RELAY_INSTALL_MODEL, fullVersion, pathFlavor) +} + +/** + * The model-parameterized form. `relay-` and `orcad-` are permanent siblings under + * one `.orca-remote/` (see remote-install-model.ts), so the prefix is an argument. + */ +export function remoteInstallDirSegments( + model: RemoteInstallModel, + fullVersion: string, + pathFlavor: RemotePathFlavor +): string[] { + const segments = [RELAY_REMOTE_DIR, remoteInstallDirName(model, fullVersion)] for (const segment of segments) { assertSafeRemotePathSegment(segment, pathFlavor) // Why: the version reaches logs and diagnostics, where an embedded CR/LF can forge lines. @@ -49,7 +66,14 @@ export function relayRemoteDirSegments( } export function relayHomeRelativeDir(fullVersion: string): string { - return relayRemoteDirSegments(fullVersion, 'posix').join('/') + return remoteInstallHomeRelativeDir(RELAY_INSTALL_MODEL, fullVersion) +} + +export function remoteInstallHomeRelativeDir( + model: RemoteInstallModel, + fullVersion: string +): string { + return remoteInstallDirSegments(model, fullVersion, 'posix').join('/') } export function createRelayInstallNamespace(homeRelativeRelayDir: string): RelayInstallNamespace { diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index e4380c319c3..d2ddc62e64b 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -7,24 +7,12 @@ import { join } from 'node:path' import { existsSync, readFileSync } from 'node:fs' import type { SshConnection } from './ssh-connection' -import { RELAY_REMOTE_DIR } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' -import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' -import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' -import { relayRemoteDirSegments } from './ssh-relay-install-namespace' +import { RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { remoteInstallDirSegments } from './ssh-relay-install-namespace' +import { RELAY_INSTALL_MODEL, type RemoteInstallModel } from './remote-install-model' import { - isRelayGcClaimOwned, - releaseRelayGcClaimWithRetry, - tryAcquireRelayGcClaim -} from './ssh-relay-gc-claim' -import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' -import { - listRelayBaseDirsCommand, - MAX_RELAY_GC_LISTING_ENTRIES, - moveRemoteTreeCommand, - probeFileExistsCommand, - probeRelayInstalledCommand, - relayLivenessProbeCommand, + probeRemoteInstallCompleteCommand, removeRemoteTreeCommand, writeRemoteEmptyFileCommand } from './ssh-remote-commands' @@ -32,20 +20,11 @@ import { getRemoteHostPlatform, isWindowsRemoteHost, joinRemotePath, - remoteBasename, type RemoteHostPlatform, type RemotePathFlavor } from './ssh-remote-platform' -import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { isSshSessionLimitError } from './ssh-session-limit-error' -// Single source of truth for GC and the version-dir parser; matches both the new and legacy relay-dir layouts. -const RELAY_VERSION_DIR_REGEX = /^relay-(v?\d+\.\d+\.\d+(\+[0-9a-f]+)?)$/ - -// Legacy dirs predate `.install-complete`; they need a liveness-only GC check so they eventually drain. -const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/ - const INSTALL_COMPLETE_NAME = '.install-complete' const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') @@ -97,13 +76,23 @@ export function computeRemoteRelayDir( remoteHome: string, fullVersion: string, pathFlavor: RemotePathFlavor = 'posix' +): string { + // Why: shell and SFTP-relative builders must derive the same validated segments or the namespaces diverge. + return computeRemoteInstallDir(RELAY_INSTALL_MODEL, remoteHome, fullVersion, pathFlavor) +} + +/** The model-parameterized form of `computeRemoteRelayDir`. */ +export function computeRemoteInstallDir( + model: RemoteInstallModel, + remoteHome: string, + fullVersion: string, + pathFlavor: RemotePathFlavor = 'posix' ): string { const host = pathFlavor === 'windows' ? getRemoteHostPlatform('win32-x64') : getRemoteHostPlatform('linux-x64') - // Why: shell and SFTP-relative builders must derive the same validated segments or the namespaces diverge. - return joinRemotePath(host, remoteHome, ...relayRemoteDirSegments(fullVersion, pathFlavor)) + return joinRemotePath(host, remoteHome, ...remoteInstallDirSegments(model, fullVersion, pathFlavor)) } /** @@ -116,11 +105,26 @@ export async function isRelayAlreadyInstalled( host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, options?: RelayInstalledProbeOptions ): Promise { + return isRemoteInstallComplete(conn, RELAY_INSTALL_MODEL, remoteRelayDir, host, options) +} + +/** The model-parameterized form: each model probes for its own artifact list. */ +export async function isRemoteInstallComplete( + conn: SshConnection, + model: RemoteInstallModel, + remoteInstallDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: RelayInstalledProbeOptions +): Promise { + const remoteRelayDir = remoteInstallDir try { const probe = await execHostCommand( conn, host, - probeRelayInstalledCommand(host, remoteRelayDir), + probeRemoteInstallCompleteCommand(host, remoteRelayDir, [ + ...model.requiredArtifacts(isWindowsRemoteHost(host)), + model.installCompleteFilename + ]), { signal: options?.signal } ) return probe.trim() === 'OK' @@ -175,188 +179,10 @@ export async function abandonInstall( * unlocked sibling version dir (never the current one). Best-effort — errors * are swallowed so GC never blocks the user from connecting. */ -export async function gcOldRelayVersions( - conn: SshConnection, - remoteHome: string, - currentDirAbsPath: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { - windowsNodePath?: string - windowsSockNames?: string[] - } -): Promise { - const baseDir = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) - const currentDirName = remoteBasename(currentDirAbsPath, host) - let listing: string - try { - listing = await execHostCommand(conn, host, listRelayBaseDirsCommand(host, baseDir)) - } catch { - return - } - const entries = listing - .split('\n') - .map((s) => s.trim()) - .filter(Boolean) - .slice(0, MAX_RELAY_GC_LISTING_ENTRIES) - - await cleanupRelayGcTombstones(conn, baseDir, entries, host) - - const candidates = entries - .filter((name) => RELAY_VERSION_DIR_REGEX.test(name)) - .filter((name) => name !== currentDirName) - - if (candidates.length === 0) { - return - } - - const removed: string[] = [] - const kept: string[] = [] - for (const name of candidates) { - const dir = joinRemotePath(host, baseDir, name) - try { - const safe = await isCandidateSafeToRemove(conn, dir, name, host, options) - if (!safe) { - kept.push(name) - continue - } - // Why: the claim is a sibling, so it survives moving/deleting the candidate and lets installers back out first. - const gcClaimToken = await tryAcquireRelayGcClaim(conn, dir, host) - if (!gcClaimToken) { - kept.push(name) - continue - } - let preserveGcClaim = false - let gcClaimReleaseNeeded = true - try { - // Recheck under the stable claim; installers probe it before and after creating their lock, closing both orders. - if (!(await isCandidateSafeToRemove(conn, dir, name, host, options))) { - kept.push(name) - continue - } - if (!(await isRelayGcClaimOwned(conn, dir, gcClaimToken, host))) { - kept.push(name) - continue - } - const tombstone = `${dir}.gc-tombstone.${process.pid}.${Date.now()}` - const moved = await execHostCommand(conn, host, moveRemoteTreeCommand(host, dir, tombstone)) - if (moved.trim() !== 'MOVED') { - kept.push(name) - continue - } - // Once renamed, a fresh install at the original path is isolated from the tombstone's deletion, so release the claim. - const release = await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) - gcClaimReleaseNeeded = release === 'unknown' - await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - preserveGcClaim = true - } - throw err - } finally { - if (!preserveGcClaim && gcClaimReleaseNeeded) { - await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) - } - } - removed.push(name) - } catch (err) { - console.warn( - `[ssh-relay] GC failed for ${dir}: ${err instanceof Error ? err.message : String(err)}` - ) - kept.push(name) - } - } - - if (removed.length > 0) { - const keptSuffix = kept.length > 0 ? ` (kept: ${kept.join(', ')})` : '' - console.log( - `[ssh-relay] GC: removed ${removed.length} stale version dir(s): ${removed.join(', ')}${keptSuffix}` - ) - } -} - -async function isCandidateSafeToRemove( - conn: SshConnection, - dir: string, - name: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { - windowsNodePath?: string - windowsSockNames?: string[] - } -): Promise { - const isLegacy = LEGACY_RELAY_DIR_REGEX.test(name) - - const lockDir = joinRemotePath(host, dir, RELAY_INSTALL_LOCK_NAME) - let lockProbe: string - try { - lockProbe = await execHostCommand(conn, host, probeInstallLockExistsCommand(host, lockDir)) - } catch { - return false - } - const lockState = lockProbe.trim() - if (lockState !== 'OPEN' && lockState !== 'LOCKED') { - return false - } - const locked = lockState === 'LOCKED' - - if (locked) { - // Why: stale lock = crashed installer; finalize can leave a dir .install-complete yet locked (lock-rm failed), so it's reclaimable. - if (!(await isRelayInstallLockStale(conn, lockDir, host))) { - return false - } - process.stderr.write?.(`[ssh-relay] GC: lock at ${lockDir} is stale; treating as recoverable\n`) - } - - // Legacy dirs predate .install-complete; skip the sentinel and rely on the live-socket probe alone. - if (!isLegacy) { - const completePath = joinRemotePath(host, dir, INSTALL_COMPLETE_NAME) - const completeProbe = await execHostCommand( - conn, - host, - probeFileExistsCommand(host, completePath) - ).catch(() => 'PARTIAL') - if (completeProbe.trim() !== 'COMPLETE') { - // Crashed-install partial; leave for the next deploy to recover. - return false - } - } - - const sockAlive = await hasLiveRelaySocket(conn, dir, host, options) - if (sockAlive) { - return false - } - return true -} - -async function hasLiveRelaySocket( - conn: SshConnection, - dir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { - windowsNodePath?: string - windowsSockNames?: string[] - } -): Promise { - try { - // Why: `test -S` only — a connect-and-close probe would race with a daemon about to idle. - const windowsOptions = - isWindowsRemoteHost(host) && options?.windowsNodePath - ? { - nodePath: options.windowsNodePath, - pipePaths: (options.windowsSockNames ?? []).flatMap((sockName) => - windowsRelayPipePathsForSocketName(host, dir, sockName) - ) - } - : undefined - const out = await execHostCommand( - conn, - host, - relayLivenessProbeCommand(host, dir, windowsOptions) - ) - const state = out.trim() - return state !== 'DEAD' && state !== 'WAITING' - } catch { - // Why: an inconclusive liveness probe must never authorize deletion. - return true - } -} +// Why re-exported rather than moved outright: deploy and the relay tests import the whole +// versioned-install surface from here, and the split exists for file size, not to redraw an API. +export { + gcOldRelayVersions, + gcOldRemoteInstallVersions, + type RemoteInstallGcOptions +} from './remote-install-gc' diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 4fa1445969d..fc3e965193e 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -2,6 +2,11 @@ import { RELAY_INSTALL_COMPLETE_FILENAME, relayArtifactFilenames } from '../../shared/relay-artifacts' +import { + RELAY_INSTALL_MODEL, + remoteInstallListingRegexSource, + type RemoteInstallModel +} from './remote-install-model' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, joinRemotePath, remoteDirname } from './ssh-remote-platform' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' @@ -99,10 +104,28 @@ export function probeRelayInstalledCommand( host: RemoteHostPlatform, remoteRelayDir: string ): string { - const required = [ + return probeRemoteInstallCompleteCommand(host, remoteRelayDir, [ ...relayArtifactFilenames(isWindowsRemoteHost(host)), RELAY_INSTALL_COMPLETE_FILENAME - ].map((filename) => joinRemotePath(host, remoteRelayDir, filename)) + ]) +} + +/** + * The model-agnostic form: any install is complete when its directory exists and every + * named artifact is a regular file inside it. + * + * Why the caller passes the list: orcad and the relay ship different artifacts, and a probe + * that checked a shared subset would call a torn install complete. + */ +export function probeRemoteInstallCompleteCommand( + host: RemoteHostPlatform, + remoteInstallDir: string, + requiredFilenames: readonly string[] +): string { + const remoteRelayDir = remoteInstallDir + const required = requiredFilenames.map((filename) => + joinRemotePath(host, remoteRelayDir, filename) + ) if (!isWindowsRemoteHost(host)) { const fileTests = required.map((path) => `&& test -f ${shellEscape(path)} `).join('') return `test -d ${shellEscape(remoteRelayDir)} ${fileTests}&& echo OK || echo MISSING` @@ -121,12 +144,28 @@ export function probeRelayInstalledCommand( export const MAX_RELAY_GC_LISTING_ENTRIES = 64 export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string { + return listRemoteInstallBaseDirsCommand(host, baseDir, RELAY_INSTALL_MODEL) +} + +/** + * List one model's version dirs (and its own tombstones) under `~/.orca-remote/`. + * + * The model scopes BOTH the `find`/`Get-ChildItem` glob and the validating regex. That + * double filter is the on-the-wire half of the GC ownership rule: an orcad GC pass never + * even receives a relay directory name, so it cannot delete one through a later bug. + */ +export function listRemoteInstallBaseDirsCommand( + host: RemoteHostPlatform, + baseDir: string, + model: RemoteInstallModel +): string { + const namePattern = remoteInstallListingRegexSource(model) if (!isWindowsRemoteHost(host)) { const statusPrefix = '__ORCA_RELAY_GC_FIND_STATUS__' return [ `base=${shellEscape(baseDir)}; [ -d "$base" ] || exit 0;`, - `{ find "$base" -mindepth 1 -maxdepth 1 -type d -name 'relay-*' -print; status=$?; printf '\n${statusPrefix}%s\n' "$status"; } |`, - String.raw`awk 'BEGIN { count=0; status=-1 } /^${statusPrefix}[0-9]+$/ { status=substr($0, ${statusPrefix.length + 1}); next } { name=$0; sub(/^.*\//, "", name); if (name ~ /^relay-(v?[0-9]+\.[0-9]+\.[0-9]+(\+[0-9a-f]+)?)(\.gc-tombstone\.[0-9]+\.[0-9]+)?$/ && count < ${MAX_RELAY_GC_LISTING_ENTRIES}) { entries[count++]=name } } END { if (status != 0) exit 1; for (i=0; i !artifact.optional).map( + (artifact) => artifact.filename + ) +} diff --git a/src/shared/runtime-capability-degradation.ts b/src/shared/runtime-capability-degradation.ts new file mode 100644 index 00000000000..05620ac9942 --- /dev/null +++ b/src/shared/runtime-capability-degradation.ts @@ -0,0 +1,64 @@ +export const TERMINAL_UNAVAILABLE_ERROR_CODE = 'terminal_unavailable' as const + +export const TERMINAL_PTY_DEGRADATION_CAPABILITY = 'terminal.pty.v1' as const + +export type RuntimeBrowserUnavailableReason = + | 'unconfigured' + | 'driver_missing' + | 'executable_not_found' + | 'executable_not_executable' + | 'electron_start_failed' + | 'chromium_start_failed' + | 'provider_unhealthy' + | 'desktop_window_unavailable' + | 'unknown' + +/** + * Why this host cannot spawn PTYs. Members are opaque to clients: render `message`, + * never switch exhaustively. Dynamic-loader failures are proved out of process because + * an incompatible native binary can terminate the host before JavaScript can catch it. + */ +export type RuntimeTerminalUnavailableReason = + | 'dependency_missing' + | 'libc_floor' + | 'abi_mismatch' + | 'load_failed' + | 'load_crashed' + | 'spawn_helper_missing' + | 'unknown' + +export type RuntimeDegradation = { + /** + * Open vocabulary. New codes ship without a protocol bump, so clients must render + * `message` and must not switch exhaustively on this or the `reason` field. + */ + code: 'browser_unavailable' | typeof TERMINAL_UNAVAILABLE_ERROR_CODE + capability: 'browser.headless.v1' | typeof TERMINAL_PTY_DEGRADATION_CAPABILITY + message: string + reason?: RuntimeBrowserUnavailableReason | RuntimeTerminalUnavailableReason + /** Underlying error text when the host has one. Diagnostic only; never load-bearing. */ + detail?: string +} + +const TERMINAL_UNAVAILABLE_MESSAGES: Record = { + dependency_missing: + 'Terminals are unavailable on this host: node-pty has no native binary for this platform. Install or rebuild it, or deploy a build that ships a prebuilt binary for this platform.', + libc_floor: + "This host's node-pty binary was built against a newer C library than the host provides, so the dynamic loader refuses it. Rebuild node-pty on this host, or deploy a build whose prebuilt binary matches this platform's libc.", + abi_mismatch: + "This host's node-pty binary was built for a different Node ABI than the running Node, so it cannot be loaded. Rebuild node-pty against this Node version.", + load_failed: 'Terminals are unavailable on this host: node-pty failed to load.', + load_crashed: + 'Terminals are unavailable on this host: loading node-pty terminated the probe process, which means the binary is incompatible with this host rather than merely missing.', + spawn_helper_missing: + 'node-pty loaded, but its spawn-helper executable is missing or not executable, so every terminal spawn would fail. Reinstall node-pty on this host.', + unknown: 'Terminals are unavailable on this host, and the cause could not be determined.' +} + +export function terminalUnavailableMessage( + reason: RuntimeTerminalUnavailableReason, + detail?: string +): string { + const base = TERMINAL_UNAVAILABLE_MESSAGES[reason] + return detail ? `${base} (${detail})` : base +} diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 9e96bf445bc..9d94980fcae 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -4,6 +4,10 @@ import type { RemoteServerUpdateSupport } from './remote-server-update' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' import type { RuntimeBrowserPlacement } from './runtime-browser-placement' import type { RuntimeCapability } from './protocol-version' +import type { + RuntimeBrowserUnavailableReason, + RuntimeDegradation +} from './runtime-capability-degradation' import type { TabGroupLayoutNode } from './tab-types' import type { TerminalColorOverrides } from './terminal-color-overrides' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from './terminal-tab-types' @@ -26,22 +30,6 @@ export type RuntimeBrowserDriverState = RuntimeTerminalDriverState export const BROWSER_UNAVAILABLE_ERROR_CODE = 'browser_unavailable' as const -/** - * Why a host declined browser automation. Members are opaque to clients: new ones - * ship without a protocol bump, so render `message` and never switch exhaustively - * (same contract as RuntimeTerminalWaitBlockedReason). - */ -export type RuntimeBrowserUnavailableReason = - | 'unconfigured' - | 'driver_missing' - | 'executable_not_found' - | 'executable_not_executable' - | 'electron_start_failed' - | 'chromium_start_failed' - | 'provider_unhealthy' - | 'desktop_window_unavailable' - | 'unknown' - // Why: one sentence per cause, each naming the thing the operator can change. The host // renders these so an older client still shows an accurate reason it cannot decode. const BROWSER_UNAVAILABLE_MESSAGES: Record = { @@ -69,19 +57,6 @@ export function browserUnavailableMessage( return detail ? `${base} (${detail})` : base } -export type RuntimeDegradation = { - code: typeof BROWSER_UNAVAILABLE_ERROR_CODE - capability: 'browser.headless.v1' - message: string - /** - * Machine-readable cause. Optional for mixed-version peers: absence means the host - * predates structured causes, NOT that the cause is 'unconfigured'. - */ - reason?: RuntimeBrowserUnavailableReason - /** Underlying error text when the host has one. Diagnostic only; never load-bearing. */ - detail?: string -} - export type RuntimeStatus = { runtimeId: string /** Authenticated requester identity. Missing for in-process callers and older hosts. */ diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 762f4274e29..6ca9dd9bf58 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -97,13 +97,21 @@ export { HEADLESS_RUNTIME_WINDOW_ID, UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH } from './runtime-session-contracts' +export { + TERMINAL_PTY_DEGRADATION_CAPABILITY, + TERMINAL_UNAVAILABLE_ERROR_CODE, + terminalUnavailableMessage +} from './runtime-capability-degradation' +export type { + RuntimeBrowserUnavailableReason, + RuntimeDegradation, + RuntimeTerminalUnavailableReason +} from './runtime-capability-degradation' export type { CliRuntimeState, CliStatusResult, DeviceScope, RuntimeBrowserDriverState, - RuntimeBrowserUnavailableReason, - RuntimeDegradation, RuntimeDesktopWindowStatus, RuntimeGraphStatus, RuntimeMobileSessionBrowserTab, From f352e3e27dc887fb96a6bd3cfe77a82aae7cc38a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:25:31 -0700 Subject: [PATCH 017/118] fix(cursor): emit Cursor-contract JSON from managed hooks Merge rebased conflict repair after exact-head tests, typecheck, lint, format, and all required GitHub checks passed. --- .github/workflows/pr.yml | 1 + config/tsconfig.cli.json | 2 + src/main/agent-hooks/installer-utils.test.ts | 11 ++ src/main/agent-hooks/installer-utils.ts | 10 +- .../remote-hook-service-installers.test.ts | 29 +-- src/main/cursor/hook-events.ts | 29 +++ src/main/cursor/hook-script.ts | 87 +++++++++ src/main/cursor/hook-service.test.ts | 168 ++++++++++++++++-- src/main/cursor/hook-service.ts | 41 ++--- 9 files changed, 323 insertions(+), 55 deletions(-) create mode 100644 src/main/cursor/hook-events.ts create mode 100644 src/main/cursor/hook-script.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 21d43d3bee5..0862ac31fe7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -712,6 +712,7 @@ jobs: src/main/wsl/wsl-w1-w3-contract.test.ts src/shared/source-scan/source-tree-scan.test.ts src/main/cli/wsl-cli-powershell-boundary.test.ts + src/main/cursor/hook-service.test.ts src/main/orca-profiles/profile-index-store.test.ts src/main/runtime/repo-worktree-admin-fingerprint.test.ts src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index eb278257159..f4555ae4a48 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -82,6 +82,8 @@ "../src/main/copilot/copilot-managed-script.ts", "../src/main/copilot/copilot-remote-hook-install.ts", "../src/main/copilot/hook-service.ts", + "../src/main/cursor/hook-events.ts", + "../src/main/cursor/hook-script.ts", "../src/main/cursor/hook-service.ts", "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 8515f415fd2..88c73550da2 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -639,6 +639,17 @@ describe('wrapWindowsHookCommand', () => { ) }) + it('emits fallback stdout when the managed script is missing', () => { + const command = wrapWindowsHookCommand( + 'C:\\hooks\\cursor-hook.cmd', + {}, + { fallbackStdout: '{"permission":"allow"}' } + ) + expect(decodeWindowsHookCommand(command)).toContain( + 'Write-Output \'{"permission":"allow"}\'; exit 0' + ) + }) + // Why: a user profile path like `C:\Users\Jane Doe` is the regression from // #6078 — the raw path used to be split at the space. The wrapper must keep // the whole path inside the encoded command so shells do not split it. diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index b5a545e1048..8667c418492 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -118,14 +118,20 @@ export { export function wrapWindowsHookCommand( scriptPath: string, - env: Record = {} + env: Record = {}, + // Why: POSIX wrap already answers missing-script with stdout; Windows must match so gate events cannot drift (#15462). + options: { fallbackStdout?: string } = {} ): string { // Why: the encoded launcher protects paths across Windows shells and drains stdin when the config points at a missing script. const quoted = quotePowerShellString(scriptPath) const envPrefix = Object.entries(env) .map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `) .join('') - const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` + const fallback = + options.fallbackStdout === undefined + ? '' + : `Write-Output ${quotePowerShellString(options.fallbackStdout)}; ` + const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; ${fallback}exit 0` return wrapWindowsPowerShellEncodedCommand(command) } diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 051b8bc48d1..bcb446b10ee 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -11,6 +11,7 @@ vi.mock('electron', () => ({ import { CodexHookService, codexHookService } from '../codex/hook-service' import { DroidHookService, droidHookService } from '../droid/hook-service' import { CursorHookService, cursorHookService } from '../cursor/hook-service' +import { CURSOR_EVENTS, type CursorEvent } from '../cursor/hook-events' import { CommandCodeHookService, commandCodeHookService } from '../command-code/hook-service' import { GeminiHookService, geminiHookService } from '../gemini/hook-service' import { AntigravityHookService, antigravityHookService } from '../antigravity/hook-service' @@ -35,6 +36,17 @@ type FakeFs = { failRenameTo: Set } +const EXPECTED_CURSOR_HOOK_RESPONSES = { + beforeSubmitPrompt: '{"continue":true}', + stop: '{}', + preToolUse: '{"permission":"allow"}', + postToolUse: '{}', + postToolUseFailure: '{}', + beforeShellExecution: '{"permission":"allow"}', + beforeMCPExecution: '{"permission":"allow"}', + afterAgentResponse: '{}' +} satisfies Record + function createFakeSftp(initialFiles: Record = {}): { sftp: SFTPWrapper fs: FakeFs @@ -366,19 +378,14 @@ describe('remote hook service installers', () => { hooks: Record } expect(cursorConfig.version).toBe(1) - for (const eventName of [ - 'beforeSubmitPrompt', - 'stop', - 'preToolUse', - 'postToolUse', - 'postToolUseFailure', - 'beforeShellExecution', - 'beforeMCPExecution', - 'afterAgentResponse' - ]) { + for (const eventName of CURSOR_EVENTS) { const definition = cursorConfig.hooks[eventName]?.[0] - expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh') + const command = definition?.command + expect(command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh') expect(definition?.hooks).toBeUndefined() + const response = EXPECTED_CURSOR_HOOK_RESPONSES[eventName] + expect(command).toContain(`ORCA_CURSOR_HOOK_RESPONSE='${response}'`) + expect(command).toContain(`printf '%s\\n' '${response}'`) } const commandCodeConfig = JSON.parse( diff --git a/src/main/cursor/hook-events.ts b/src/main/cursor/hook-events.ts new file mode 100644 index 00000000000..fe840a41b72 --- /dev/null +++ b/src/main/cursor/hook-events.ts @@ -0,0 +1,29 @@ +// Subscribe only to Cursor hooks needed for spinner and turn detection. +// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache. +export const CURSOR_EVENTS = [ + 'beforeSubmitPrompt', + 'stop', + 'preToolUse', + 'postToolUse', + 'postToolUseFailure', + 'beforeShellExecution', + 'beforeMCPExecution', + 'afterAgentResponse' +] as const + +export type CursorEvent = (typeof CURSOR_EVENTS)[number] + +const CURSOR_HOOK_RESPONSES = { + beforeSubmitPrompt: '{"continue":true}', + stop: '{}', + preToolUse: '{"permission":"allow"}', + postToolUse: '{}', + postToolUseFailure: '{}', + beforeShellExecution: '{"permission":"allow"}', + beforeMCPExecution: '{"permission":"allow"}', + afterAgentResponse: '{}' +} satisfies Record + +export function getCursorHookResponse(eventName: CursorEvent): string { + return CURSOR_HOOK_RESPONSES[eventName] +} diff --git a/src/main/cursor/hook-script.ts b/src/main/cursor/hook-script.ts new file mode 100644 index 00000000000..94563337131 --- /dev/null +++ b/src/main/cursor/hook-script.ts @@ -0,0 +1,87 @@ +import { + buildWindowsAgentHookPostCommand, + wrapPosixHookCommand, + wrapWindowsHookCommand +} from '../agent-hooks/installer-utils' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { getCursorHookResponse, type CursorEvent } from './hook-events' + +const CURSOR_HOOK_RESPONSE_ENV = 'ORCA_CURSOR_HOOK_RESPONSE' + +export function getPosixManagedCommand(scriptPath: string, eventName: CursorEvent): string { + const response = getCursorHookResponse(eventName) + return wrapPosixHookCommand( + scriptPath, + { [CURSOR_HOOK_RESPONSE_ENV]: response }, + { fallbackStdout: response } + ) +} + +export function getManagedCommand(scriptPath: string, eventName: CursorEvent): string { + const response = getCursorHookResponse(eventName) + return process.platform === 'win32' + ? wrapWindowsHookCommand( + scriptPath, + { [CURSOR_HOOK_RESPONSE_ENV]: response }, + { fallbackStdout: response } + ) + : getPosixManagedCommand(scriptPath, eventName) +} + +export function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: Cursor permission hooks fail closed on empty/invalid stdout (#15462). + `if defined ${CURSOR_HOOK_RESPONSE_ENV} (echo %${CURSOR_HOOK_RESPONSE_ENV}%) else (echo {})`, + // Why: source current endpoint coordinates for PTYs surviving an Orca restart. + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookPostCommand('cursor'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + // Why: Cursor permission hooks fail closed on empty/invalid stdout (#15462). + `if [ -n "$${CURSOR_HOOK_RESPONSE_ENV}" ]; then`, + ` printf '%s\\n' "$${CURSOR_HOOK_RESPONSE_ENV}"`, + 'else', + ' printf "{}\\n"', + 'fi', + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('cursor'), + // Why: refresh endpoint coordinates so surviving PTYs keep reporting. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + // Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON. + // Why: pipe payload to curl stdin to keep large output off the command line. + 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', + 'exit 0', + '' + ].join('\n') +} diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index a260b74938f..36f6aa94b48 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { findGitBash } from '../agent-hooks/windows-git-bash-path.test-fixture' const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() @@ -17,22 +19,66 @@ vi.mock('os', async () => { import { CursorHookService } from './hook-service' import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' - -const CURSOR_EVENTS = [ - 'beforeSubmitPrompt', - 'stop', - 'preToolUse', - 'postToolUse', - 'postToolUseFailure', - 'beforeShellExecution', - 'beforeMCPExecution', - 'afterAgentResponse' -] +import { CURSOR_EVENTS, type CursorEvent } from './hook-events' const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ +type InstalledCursorHooks = { + hooks: Record +} + +const EXPECTED_CURSOR_HOOK_STDOUT = { + beforeSubmitPrompt: { continue: true }, + stop: {}, + preToolUse: { permission: 'allow' }, + postToolUse: {}, + postToolUseFailure: {}, + beforeShellExecution: { permission: 'allow' }, + beforeMCPExecution: { permission: 'allow' }, + afterAgentResponse: {} +} satisfies Record> + +function readInstalledCursorHooks(homeDir: string): InstalledCursorHooks { + return JSON.parse( + readFileSync(join(homeDir, '.cursor', 'hooks.json'), 'utf8') + ) as InstalledCursorHooks +} + +function requireRegisteredCommand(config: InstalledCursorHooks, eventName: string): string { + const command = config.hooks[eventName]?.[0]?.command + expect(command, eventName).toEqual(expect.any(String)) + if (typeof command !== 'string') { + throw new Error(`missing Cursor hook command for ${eventName}`) + } + return command +} + +function runRegisteredCursorHook( + command: string, + input: string, + extraEnv: NodeJS.ProcessEnv = {} +): { stdout: string; stderr: string; status: number | null } { + const executable = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh' + const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command] + const result = spawnSync(executable, args, { + encoding: 'utf8', + input, + timeout: 15_000, + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: '', + ORCA_AGENT_HOOK_TOKEN: '', + ORCA_PANE_KEY: '', + ...extraEnv + } + }) + expect(result.error, result.stderr).toBeUndefined() + return { stdout: result.stdout, stderr: result.stderr, status: result.status } +} + describe('CursorHookService', () => { let homeDir: string @@ -156,4 +202,102 @@ describe('CursorHookService', () => { '/usr/local/bin/retired-user-hook' ]) }) + + // Why: installer-intent assertions missed empty stdout, which Cursor treats as + // invalid JSON and fails closed (#15462). This runs the registered command. + it('emits protocol-valid JSON on stdout for every managed event, including empty stdin (#15462)', () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + const payloads = [ + (eventName: string) => JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }), + () => '' + ] + + for (const eventName of CURSOR_EVENTS) { + const command = requireRegisteredCommand(config, eventName) + for (const payloadFor of payloads) { + const result = runRegisteredCursorHook(command, payloadFor(eventName)) + expect(result.status, `${eventName} exit`).toBe(0) + expect(result.stderr, `${eventName} stderr`).toBe('') + expect(JSON.parse(result.stdout), `${eventName} stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + } + }) + + it('emits protocol-valid JSON when the managed Cursor script is missing (#15462)', () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + unlinkSync(join(homeDir, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME)) + + for (const eventName of CURSOR_EVENTS) { + const command = requireRegisteredCommand(config, eventName) + const result = runRegisteredCursorHook(command, '') + expect(result.status, `${eventName} missing-script exit`).toBe(0) + expect(result.stderr, `${eventName} missing-script stderr`).toBe('') + expect(JSON.parse(result.stdout), `${eventName} missing-script stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + }) + + it('keeps curl failure off stdout when the listener is unreachable (#15462)', () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + + for (const eventName of ['beforeSubmitPrompt', 'preToolUse', 'stop'] as const) { + const command = requireRegisteredCommand(config, eventName) + const result = runRegisteredCursorHook( + command, + JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }), + { + ORCA_AGENT_HOOK_PORT: '59999', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'tab:leaf' + } + ) + expect(result.status, `${eventName} dead-listener exit`).toBe(0) + expect(JSON.parse(result.stdout), `${eventName} dead-listener stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + }) + + it.skipIf(process.platform !== 'win32')( + 'emits parseable JSON through cmd.exe and Git Bash (#14825/#15462)', + () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + const gitBash = findGitBash() + const shells = [ + { name: 'cmd.exe', executable: 'cmd.exe', args: ['/d', '/c'] }, + { name: 'Git Bash', executable: gitBash, args: ['-c'] } + ] + for (const eventName of ['beforeSubmitPrompt', 'preToolUse'] as const) { + const command = requireRegisteredCommand(config, eventName) + for (const shell of shells) { + const result = spawnSync(shell.executable, [...shell.args, command], { + encoding: 'utf8', + input: JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }), + timeout: 15_000, + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: '', + ORCA_AGENT_HOOK_TOKEN: '', + ORCA_PANE_KEY: '', + USERPROFILE: homeDir + } + }) + expect(result.error, `${eventName} ${shell.name}`).toBeUndefined() + expect(result.status, `${eventName} ${shell.name} exit`).toBe(0) + expect(result.stderr, `${eventName} ${shell.name} stderr`).toBe('') + expect(JSON.parse(result.stdout), `${eventName} ${shell.name} stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + } + } + ) }) diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 310afad939c..b07639f108a 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -5,12 +5,9 @@ import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared import { buildManagedCommandDefinition, createManagedCommandMatcher, - buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, - wrapPosixHookCommand, - wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -21,25 +18,8 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' -import { - buildPosixHookPayloadCapture, - buildPosixHookSpoolLines, - buildWindowsHookEnvironmentGuardLines, - buildWindowsHookStdinDrainEpilogue -} from '../agent-hooks/hook-stdin-contract' - -// Subscribe only to Cursor hooks needed for spinner and turn detection. -// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache. -const CURSOR_EVENTS = [ - 'beforeSubmitPrompt', - 'stop', - 'preToolUse', - 'postToolUse', - 'postToolUseFailure', - 'beforeShellExecution', - 'beforeMCPExecution', - 'afterAgentResponse' -] as const +import { CURSOR_EVENTS } from './hook-events' +import { getManagedCommand, getManagedScript, getPosixManagedCommand } from './hook-script' function getConfigPath(): string { return join(homedir(), '.cursor', 'hooks.json') @@ -53,6 +33,7 @@ function getManagedScriptPath(): string { return getSharedManagedScriptPath(getManagedScriptFileName()) } +/* function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' ? wrapWindowsHookCommand(scriptPath) @@ -103,7 +84,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '' ].join('\n') } - +*/ export class CursorHookService { async refreshManagedScripts(): Promise { await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) @@ -123,10 +104,10 @@ export class CursorHookService { } } - const command = getManagedCommand(scriptPath) const missing: string[] = [] let presentCount = 0 for (const eventName of CURSOR_EVENTS) { + const command = getManagedCommand(scriptPath, eventName) const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] // Why: Cursor puts command directly on the definition (Claude nests under `hooks`); match both shapes. const hasCommand = definitions.some( @@ -170,7 +151,6 @@ export class CursorHookService { } } - const command = getManagedCommand(scriptPath) // Why: config.hooks is undefined on a fresh file with no prior hook install. const nextHooks = { ...config.hooks } const managedEvents = new Set(CURSOR_EVENTS) @@ -189,7 +169,7 @@ export class CursorHookService { const cleaned = removeManagedCommands(definitions, isManagedCommand) // Also strip entries with the command at the top level (Cursor schema). const strippedCursorShape = cleaned.filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) if (strippedCursorShape.length === 0) { delete nextHooks[eventName] @@ -199,10 +179,11 @@ export class CursorHookService { } for (const eventName of CURSOR_EVENTS) { + const command = getManagedCommand(scriptPath, eventName) const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] // Sweep Claude- and Cursor-shaped variants so installs converge on one entry. const cleaned = removeManagedCommands(current, isManagedCommand).filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) // Why: Cursor's schema puts `command` directly on the definition (not under `hooks`); emit that shape. const definition: HookDefinition = buildManagedCommandDefinition(command) @@ -235,15 +216,15 @@ export class CursorHookService { } } - const command = wrapPosixHookCommand(remoteScriptPath) const nextHooks = { ...config.hooks } const isManagedCommand = createManagedCommandMatcher('cursor-hook.sh') for (const eventName of CURSOR_EVENTS) { + const command = getPosixManagedCommand(remoteScriptPath, eventName) const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] // Why: dual-shape sweep so repeated installs converge on a single managed entry. const cleaned = removeManagedCommands(current, isManagedCommand).filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) const definition: HookDefinition = buildManagedCommandDefinition(command) nextHooks[eventName] = [...cleaned, definition] @@ -297,7 +278,7 @@ export class CursorHookService { continue } const cleaned = removeManagedCommands(definitions, isManagedCommand).filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) if (cleaned.length === 0) { delete nextHooks[eventName] From 026389a3bc03da03ca2d65295e805493712b0774 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:26:55 -0700 Subject: [PATCH 018/118] Suppress default quit on window-all-closed in DNS probe (#16683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Suppress default quit on window-all-closed in DNS probe Destroying the probe window awaits stopLogging, which yields to the event loop long enough for the default window-all-closed exit (non-macOS) to trigger before the result can be written. Preventing this default behavior allows the result to complete and exit cleanly. * Preserve Japanese Skills UI labels; fix test flakiness and deps - Add skipKeyPrefixes filter to Japanese phrase fixes to prevent automatic translation of UI labels (e.g., keep エージェント in Skills components) - Wrap timing-dependent tests in vi.waitFor to eliminate race conditions during reconciliation and scheduler boundary ticks - Complete useEffect dependency arrays to resolve React hook warnings * Fix updater startup scheduling test flakiness Set last update check to 23h ago instead of null to make timing deterministic. The startup check arms its own 24h timer; by pre-setting the last check time, only the result handler's re-arm can produce the expected check 24h later, eliminating race conditions. --- config/scripts/locale-ja-phrase-fixes.mjs | 8 +++++++- ...nslation-policy.ja-relocalization.test.mjs | 10 ++++++++++ config/scripts/locale-translation-policy.mjs | 11 +++++++---- ...rowser-route-dns-prefetch-electron-main.ts | 4 ++++ .../ipc/worktree-git-common-watch.test.ts | 15 ++++++++++++--- src/main/updater.startup-scheduling.test.ts | 13 +++++++++++-- src/renderer/src/components/TaskPage.tsx | 19 ++++++++++++++++--- 7 files changed, 67 insertions(+), 13 deletions(-) diff --git a/config/scripts/locale-ja-phrase-fixes.mjs b/config/scripts/locale-ja-phrase-fixes.mjs index 504c5d25889..8c80544eba2 100644 --- a/config/scripts/locale-ja-phrase-fixes.mjs +++ b/config/scripts/locale-ja-phrase-fixes.mjs @@ -37,7 +37,13 @@ const RELOCALIZED_GENERIC_TERMS = [ export const JA_PHRASE_FIXES = [ ...RELOCALIZED_GENERIC_TERMS, - { pattern: /エージェント/g, replacement: 'Agent', whenEnIncludes: 'agent' }, + { + pattern: /エージェント/g, + replacement: 'Agent', + whenEnIncludes: 'agent', + // Skills filters and metadata are Japanese UI labels, not agent product prose. + skipKeyPrefixes: ['auto.components.skills.'] + }, { pattern: /解雇/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' }, { pattern: /却下/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' }, { pattern: /代理人/g, replacement: 'Agent', whenEnIncludes: 'agent' }, diff --git a/config/scripts/locale-translation-policy.ja-relocalization.test.mjs b/config/scripts/locale-translation-policy.ja-relocalization.test.mjs index c91909ad69d..26ff48c878c 100644 --- a/config/scripts/locale-translation-policy.ja-relocalization.test.mjs +++ b/config/scripts/locale-translation-policy.ja-relocalization.test.mjs @@ -79,6 +79,16 @@ describe('locale-translation-policy ja relocalization', () => { expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く') }) + it('preserves translated Japanese Skills labels', () => { + expect( + ja( + 'Filter by agent', + 'エージェントで絞り込み', + 'auto.components.skills.SkillsPage.filterProvider' + ) + ).toBe('エージェントで絞り込み') + }) + it('leaves ranges and token samples out of the ellipsis rule', () => { expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較') }) diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index eb1a7ea0b92..9fd4350ee45 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -351,12 +351,15 @@ function phraseFixMatchesEnglish(enValue, fix) { return enValue.toLowerCase().includes(fix.whenEnIncludes.toLowerCase()) } -function applyPhraseFixes(enValue, localeValue, locale) { +function applyPhraseFixes(enValue, localeValue, locale, key = '') { let result = localeValue for (const fix of LOCALE_PHRASE_FIXES[locale] ?? []) { if (!phraseFixMatchesEnglish(enValue, fix)) { continue } + if (fix.skipKeyPrefixes?.some((prefix) => key.startsWith(prefix))) { + continue + } result = result.replace(fix.pattern, fix.replacement) } return result @@ -367,7 +370,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) { if (keyOverride) { // Why: exact key overrides can still carry stale MT output, so glossary repairs remain the final gate. let result = applyBrandMistranslationFixes(enValue, keyOverride, locale, key) - result = applyPhraseFixes(enValue, result, locale) + result = applyPhraseFixes(enValue, result, locale, key) if (['zh', 'ja', 'ko'].includes(locale)) { result = applyCjkLatinTermSpacing(result, locale) } @@ -377,7 +380,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) { const valueOverride = LOCALE_VALUE_OVERRIDES[locale]?.[enValue] if (valueOverride) { let result = applyBrandMistranslationFixes(enValue, valueOverride, locale, key) - result = applyPhraseFixes(enValue, result, locale) + result = applyPhraseFixes(enValue, result, locale, key) if (['zh', 'ja', 'ko'].includes(locale)) { result = applyCjkLatinTermSpacing(result, locale) } @@ -398,7 +401,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) { } result = applyBrandMistranslationFixes(enValue, result, locale, key) - result = applyPhraseFixes(enValue, result, locale) + result = applyPhraseFixes(enValue, result, locale, key) if (['zh', 'ja', 'ko'].includes(locale)) { result = applyCjkLatinTermSpacing(result, locale) } diff --git a/src/main/browser/browser-route-dns-prefetch-electron-main.ts b/src/main/browser/browser-route-dns-prefetch-electron-main.ts index 5d98d5e3816..d53bb721f34 100644 --- a/src/main/browser/browser-route-dns-prefetch-electron-main.ts +++ b/src/main/browser/browser-route-dns-prefetch-electron-main.ts @@ -43,6 +43,10 @@ async function probe() { return { resolvedProxy } } +// Why: destroying the probe window awaits stopLogging, which yields long enough for the default +// window-all-closed quit (non-macOS) to exit 0 before the result is written. +app.on('window-all-closed', () => {}) + async function run() { const timeout = setTimeout(() => app.exit(2), 25000) await app.whenReady() diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index cc893b8604a..619133263de 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -752,9 +752,18 @@ describe('worktree git-common narrow watch (local native platforms)', () => { const stalePendingSubscription = narrowSubscriptions()[1] await replaceWorktreesRoot(commonDir, worktreesDir, retainedEntry) await vi.advanceTimersByTimeAsync(POLL_MS * RECONCILIATION_TICKS * 4) - expect( - received.flat().filter((event) => event.type === 'create' && event.path === worktreesDir) - ).toHaveLength(2) + // Reconciliation's snapshot is real filesystem work, so advancing the fake + // clock schedules the second replacement's tick but does not settle it. + await vi.waitFor( + () => { + expect( + received + .flat() + .filter((event) => event.type === 'create' && event.path === worktreesDir) + ).toHaveLength(2) + }, + { timeout: 2_000 } + ) const beforeStaleInterruption = received.length stalePendingSubscription.hooks.onInterruption?.() diff --git a/src/main/updater.startup-scheduling.test.ts b/src/main/updater.startup-scheduling.test.ts index 369cf85d1dd..6cf7cf5f00d 100644 --- a/src/main/updater.startup-scheduling.test.ts +++ b/src/main/updater.startup-scheduling.test.ts @@ -216,11 +216,17 @@ describe('updater', () => { const { setupAutoUpdater } = await import('./updater') + // Why: a startup check also arms its own 24h timer, which would fire at the same boundary as the + // reschedule under test; entering 23h in makes the startup timer fire the check itself, so only + // the result handler's re-arm can produce a check 24h later. setupAutoUpdater(mainWindow as never, { - getLastUpdateCheckAt: () => null, + getLastUpdateCheckAt: () => Date.now() - 23 * 60 * 60 * 1000, setLastUpdateCheckAt }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) await vi.waitFor(() => { expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) }) @@ -237,8 +243,11 @@ describe('updater', () => { expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(60 * 1000) + // Why: the boundary tick sweeps the updater's other timers (30-minute nudge poll, 45-second + // stall guard) too, so pin the reschedule itself — nothing before 24h, a check once it elapses — + // rather than an exact process-wide call total. await vi.waitFor(() => { - expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + expect(autoUpdaterMock.checkForUpdates.mock.calls.length).toBeGreaterThanOrEqual(2) }) }) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 901fcdf3f6e..4ef3cc430f6 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -2179,7 +2179,7 @@ export default function TaskPage(): React.JSX.Element { setAppliedLinearProjectSearch(linearProjectSearchInput) }, TASK_SEARCH_DEBOUNCE_MS) return () => window.clearTimeout(timeout) - }, [linearProjectSearchInput, taskResumeApplied]) + }, [linearProjectSearchInput, setAppliedLinearProjectSearch, taskResumeApplied]) useEffect(() => { if (!taskResumeApplied || taskSource !== 'linear' || linearMode !== 'projects') { @@ -2274,6 +2274,12 @@ export default function TaskPage(): React.JSX.Element { fetchLinearProject, linearRefreshNonce, selectedLinearProject, + setLinearProjectDetailError, + setLinearProjectDetailLoading, + setLinearProjectParentView, + setLinearProjectsError, + setSelectedLinearProject, + setSelectedLinearProjectDetail, setTaskResumeState, linearTaskSourceContext ]) @@ -2315,7 +2321,10 @@ export default function TaskPage(): React.JSX.Element { linearRefreshNonce, listLinearProjectIssues, linearTaskSourceContext, - selectedLinearProject + selectedLinearProject, + setLinearProjectIssuesError, + setLinearProjectIssuesLoading, + setLinearProjectIssuesResult ]) useEffect(() => { @@ -2433,7 +2442,11 @@ export default function TaskPage(): React.JSX.Element { listLinearCustomViewIssues, listLinearCustomViewProjects, linearTaskSourceContext, - selectedLinearCustomView + selectedLinearCustomView, + setLinearCustomViewContentsError, + setLinearCustomViewContentsLoading, + setLinearCustomViewIssuesResult, + setLinearCustomViewProjectsResult ]) useEffect(() => { From 07b7e9e68d304d24af068ed8aa6b4bfec0ee718b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:49:02 -0700 Subject: [PATCH 019/118] Open target=_blank links and unnamed popups in new Orca tabs (#16720) * feat(browser): open target=_blank links and unnamed popups in new Orca t - Treat target=_blank as a new-tab request matching browser behavior - Route unnamed, featureless window.open() calls to Orca tabs instead of native popups - Add rate limiting to prevent page-initiated tab loops - Inherit session profiles when opening links to maintain isolation boundaries * fix(browser): deny new-tab window.open when renderer is destroyed Move deny action outside conditional to ensure new-tab intents are safely rejected even if renderer vanishes mid-open, preventing native popup fallthrough. Add test coverage and simplify comments. * Share page-initiated tab budget across opener popup tree Prevent pages from bypassing the new-tab rate limit by chaining popup windows. The page-initiated tab quota is now shared by all popups in an opener tree (root + named children), so child windows inherit their root's budget instead of each getting a fresh allocation. --- .../browser-clicked-link-routing.test.ts | 28 +- .../browser/browser-clicked-link-routing.ts | 26 +- .../browser-manager-popup-child.test.ts | 253 ++++++++++++ .../browser-manager-popup-routing.test.ts | 381 +++++++++--------- src/main/browser/browser-manager.ts | 51 ++- .../browser-page-initiated-tab-budget.test.ts | 34 ++ .../browser-page-initiated-tab-budget.ts | 26 ++ .../browser/browser-popup-new-tab-intent.ts | 17 + .../ipc-events/browser-state-ipc-bridge.ts | 15 +- .../browser-state-open-link-profile.test.ts | 104 +++++ tests/e2e/browser-tab.spec.ts | 23 +- 11 files changed, 708 insertions(+), 250 deletions(-) create mode 100644 src/main/browser/browser-manager-popup-child.test.ts create mode 100644 src/main/browser/browser-page-initiated-tab-budget.test.ts create mode 100644 src/main/browser/browser-page-initiated-tab-budget.ts create mode 100644 src/main/browser/browser-popup-new-tab-intent.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts diff --git a/src/main/browser/browser-clicked-link-routing.test.ts b/src/main/browser/browser-clicked-link-routing.test.ts index eee5eea35f1..24c26471c3a 100644 --- a/src/main/browser/browser-clicked-link-routing.test.ts +++ b/src/main/browser/browser-clicked-link-routing.test.ts @@ -64,7 +64,7 @@ describe('browser clicked-link routing', () => { vi.restoreAllMocks() }) - it('routes plain target=_blank links back into the current Orca tab', () => { + it('routes plain target=_blank links into a new Orca tab', () => { const link = document.createElement('a') link.href = 'https://docs.example.com/guide' link.target = '_blank' @@ -73,9 +73,8 @@ describe('browser clicked-link routing', () => { const { event, open } = clickLink(link) - expect(event.defaultPrevented).toBe(false) - expect(open).not.toHaveBeenCalled() - expect(link.getAttribute('target')).toBe('_self') + expect(event.defaultPrevented).toBe(true) + expect(open).toHaveBeenCalledWith('https://docs.example.com/guide', FOREGROUND_FRAME_NAME) }) it('routes the host-platform modifier without trusting an emulated guest user agent', () => { @@ -127,9 +126,11 @@ describe('browser clicked-link routing', () => { expect(clickLink(cancelled).open).not.toHaveBeenCalled() expect(cancelled.getAttribute('target')).toBe('_blank') - expect(clickLink(rewritten).open).not.toHaveBeenCalled() - expect(rewritten.href).toBe('https://example.com/rewritten') - expect(rewritten.getAttribute('target')).toBe('_self') + // The page's own handler ran first, so routing must follow the rewritten href. + expect(clickLink(rewritten).open).toHaveBeenCalledWith( + 'https://example.com/rewritten', + FOREGROUND_FRAME_NAME + ) }) it('routes SVG links but leaves download links and links without href alone', () => { @@ -145,8 +146,10 @@ describe('browser clicked-link routing', () => { document.body.append(svgLink, areaDownload, noHref) installRouting() - expect(clickLink(svgLink).open).not.toHaveBeenCalled() - expect(svgLink.getAttribute('target')).toBe('_self') + expect(clickLink(svgLink).open).toHaveBeenCalledWith( + 'https://example.com/svg', + FOREGROUND_FRAME_NAME + ) expect(clickLink(areaDownload).open).not.toHaveBeenCalled() expect(clickLink(noHref).open).not.toHaveBeenCalled() }) @@ -195,7 +198,7 @@ describe('browser clicked-link routing', () => { expect(script).not.toContain('BrowserClickedLinkRoutingState') }) - it('routes plain iframe target=_blank links into the top-level guest', () => { + it('routes plain iframe target=_blank links into a new Orca tab', () => { const link = document.createElement('a') link.href = 'https://example.com/from-frame' link.target = '_blank' @@ -204,9 +207,8 @@ describe('browser clicked-link routing', () => { const { event, open } = clickLink(link) - expect(event.defaultPrevented).toBe(false) - expect(open).not.toHaveBeenCalled() - expect(link.getAttribute('target')).toBe('_top') + expect(event.defaultPrevented).toBe(true) + expect(open).toHaveBeenCalledWith('https://example.com/from-frame', FOREGROUND_FRAME_NAME) }) it('routes explicit iframe new-tab gestures through one-use frame names', () => { diff --git a/src/main/browser/browser-clicked-link-routing.ts b/src/main/browser/browser-clicked-link-routing.ts index 42a8bbf8232..ee6f2fc43d5 100644 --- a/src/main/browser/browser-clicked-link-routing.ts +++ b/src/main/browser/browser-clicked-link-routing.ts @@ -75,8 +75,9 @@ export function installBrowserClickedLinkRouting( const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' const ownTarget = link.getAttribute('target') const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() - const opensNewContext = middleClick || modifierClick - if (!opensNewContext && effectiveTarget !== '_blank') { + // target=_blank is a new-tab request, exactly as it is in every other + // browser; the modifiers are the other two ways to ask for one. + if (!(middleClick || modifierClick || effectiveTarget === '_blank')) { return } @@ -96,13 +97,6 @@ export function installBrowserClickedLinkRouting( return } - if (!opensNewContext) { - // Why: changing only the browsing context keeps Chromium's native anchor - // navigation, including referrer policy, attribution, and history. - link.setAttribute('target', '_self') - return - } - // Why: Electron reports direct link clicks and featureless window.open() // with the same disposition. The private frame name preserves that one // distinction without weakening OAuth popups that need window.opener. @@ -118,8 +112,8 @@ export function installBrowserClickedLinkRouting( } /** - * Keeps plain target=_blank clicks inside the top-level guest when Electron's - * isolated-world API cannot target a child frame. + * Same routing for links inside child frames, which Electron's isolated-world + * API cannot reach, so this runs in the page world against a one-use token. */ export function installBrowserIframeClickedLinkRouting( frameName: string, @@ -161,8 +155,7 @@ export function installBrowserIframeClickedLinkRouting( const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' const ownTarget = link.getAttribute('target') const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() - const opensNewContext = middleClick || modifierClick - if (!opensNewContext && effectiveTarget !== '_blank') { + if (!(middleClick || modifierClick || effectiveTarget === '_blank')) { return } @@ -182,13 +175,6 @@ export function installBrowserIframeClickedLinkRouting( return } - if (!opensNewContext) { - // Why: WebContents isolated worlds only cover the main frame. Rewriting - // to `_top` preserves native anchor semantics without opening a popup. - link.setAttribute('target', '_top') - return - } - // Why: child-frame code runs in the page world, so each token is one-use. // A page that observes a real click cannot replay it to create more tabs. event.preventDefault() diff --git a/src/main/browser/browser-manager-popup-child.test.ts b/src/main/browser/browser-manager-popup-child.test.ts new file mode 100644 index 00000000000..bf4caf2b0ba --- /dev/null +++ b/src/main/browser/browser-manager-popup-child.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + shellOpenExternalMock: vi.fn(), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) + +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import { + createDownloadItem, + getDownloadItemEventHandler, + rendererWebContentsId, + resetBrowserManagerMocks, + resetBrowserManagerState +} from './browser-manager-test-harness' + +const { + guestOffMock, + guestOnMock, + guestSetBackgroundThrottlingMock, + guestSetWindowOpenHandlerMock, + guestOpenDevToolsMock, + webContentsFromIdMock, + shellOpenExternalMock +} = browserMocks + +describe('browserManager popup child policies', () => { + beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('attaches guest policies to created popup child windows', () => { + const rendererSendMock = vi.fn() + const childSetBackgroundThrottlingMock = vi.fn() + const childSetWindowOpenHandlerMock = vi.fn() + const childOnMock = vi.fn() + const childOffMock = vi.fn() + const childOpenDevToolsMock = vi.fn() + const childGuest = { + id: 4040, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: childSetBackgroundThrottlingMock, + setWindowOpenHandler: childSetWindowOpenHandlerMock, + on: childOnMock, + off: childOffMock, + openDevTools: childOpenDevToolsMock + } + const guest = { + id: 404, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const didCreateWindowHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'did-create-window' + )?.[1] as ((window: { webContents: typeof childGuest }) => void) | undefined + expect(didCreateWindowHandler).toBeTypeOf('function') + + didCreateWindowHandler?.({ webContents: childGuest }) + + expect(childSetBackgroundThrottlingMock).toHaveBeenCalledWith(false) + expect(childSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1) + expect(childOnMock.mock.calls.filter(([event]) => event === 'did-create-window')).toHaveLength( + 1 + ) + expect(childOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1) + expect(childOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1) + + const childWindowOpenHandler = childSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + }) => { action: 'allow' | 'deny' } + expect(childWindowOpenHandler({ url: 'https://identity.example.com/login' })).toMatchObject({ + action: 'allow' + }) + expect(childWindowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'null', + action: 'blocked' + }) + browserManager.notifyPermissionDenied({ + guestWebContentsId: childGuest.id, + permission: 'notifications', + rawUrl: 'https://identity.example.com/login' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', { + browserPageId: 'browser-1', + permission: 'notifications', + origin: 'https://identity.example.com' + }) + + const childDidFailLoadHandler = childOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as + | (( + event: Electron.Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void) + | undefined + childDidFailLoadHandler?.( + {} as Electron.Event, + -105, + 'Name not resolved', + 'https://identity.example.com/unavailable', + true + ) + expect(rendererSendMock).not.toHaveBeenCalledWith( + 'browser:guest-load-failed', + expect.anything() + ) + + const childDownloadItem = createDownloadItem() + browserManager.handleGuestWillDownload({ + guestWebContentsId: childGuest.id, + item: childDownloadItem + }) + expect(rendererSendMock).toHaveBeenCalledWith( + 'browser:download-requested', + expect.objectContaining({ browserPageId: 'browser-1' }) + ) + const childDownloadDoneHandler = getDownloadItemEventHandler(childDownloadItem, 'once', 'done') + childDownloadDoneHandler?.({} as Electron.Event, 'completed') + + const managerState = browserManager as unknown as { + popupOwnerContextByGuestId: Map + } + expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(true) + + const cleanupChildOnMock = vi.fn() + const cleanupChildGuest = { + ...childGuest, + id: 4041, + on: cleanupChildOnMock, + off: vi.fn(), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn() + } + const childDidCreateWindowHandler = childOnMock.mock.calls.find( + ([event]) => event === 'did-create-window' + )?.[1] as ((window: { webContents: typeof cleanupChildGuest }) => void) | undefined + childDidCreateWindowHandler?.({ webContents: cleanupChildGuest }) + expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(true) + const cleanupChildWindowOpenHandler = cleanupChildGuest.setWindowOpenHandler.mock + .calls[0][0] as (details: { url: string }) => { action: 'allow' | 'deny' } + expect( + cleanupChildWindowOpenHandler({ url: 'https://identity.example.com/continue' }) + ).toMatchObject({ action: 'allow' }) + const cleanupChildDestroyedHandler = cleanupChildOnMock.mock.calls.find( + ([event]) => event === 'destroyed' + )?.[1] as (() => void) | undefined + cleanupChildDestroyedHandler?.() + expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(false) + + const replacementGuest = { + ...guest, + id: 405, + on: vi.fn(), + off: vi.fn(), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn() + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === replacementGuest.id) { + return replacementGuest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + browserManager.attachGuestPolicies(replacementGuest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: replacementGuest.id, + rendererWebContentsId + }) + + expect(childWindowOpenHandler({ url: 'https://identity.example.com/next' })).toEqual({ + action: 'deny' + }) + expect(shellOpenExternalMock).toHaveBeenCalledWith('https://identity.example.com/next') + expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) + + const childDestroyedHandler = childOnMock.mock.calls.find( + ([event]) => event === 'destroyed' + )?.[1] as (() => void) | undefined + childDestroyedHandler?.() + expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) + + browserManager.unregisterAll() + + expect(childOffMock).toHaveBeenCalledWith('did-create-window', expect.any(Function)) + expect(childOffMock).toHaveBeenCalledWith('will-navigate', expect.any(Function)) + expect(childOffMock).toHaveBeenCalledWith('will-redirect', expect.any(Function)) + }) +}) diff --git a/src/main/browser/browser-manager-popup-routing.test.ts b/src/main/browser/browser-manager-popup-routing.test.ts index 89b4d2fe2a8..a3083005016 100644 --- a/src/main/browser/browser-manager-popup-routing.test.ts +++ b/src/main/browser/browser-manager-popup-routing.test.ts @@ -40,9 +40,8 @@ vi.mock('./popup-origin-bar-window', () => ({ })) import { browserManager } from './browser-manager' +import { MAX_PAGE_INITIATED_TABS_PER_WINDOW } from './browser-page-initiated-tab-budget' import { - createDownloadItem, - getDownloadItemEventHandler, rendererWebContentsId, resetBrowserManagerMocks, resetBrowserManagerState @@ -169,11 +168,12 @@ describe('browserManager', () => { expect(rendererSendMock).not.toHaveBeenCalled() }) - it('keeps featureless window.open popups in-app for every disposition', () => { + it('keeps opener-dependent window.open popups in-app for every disposition', () => { // Regression guard for the reverted #8332: gating the allow on // disposition === 'new-window' silently broke featureless window.open() - // OAuth flows (disposition 'foreground-tab'), whose returned handle must - // stay live. Disposition is a UX hint, not a trust signal. + // OAuth flows, whose returned handle must stay live. A named target, a + // features string, and a blank URL each mark such a flow, so all three keep + // a real child window no matter which disposition Chromium reports. const guest = { id: 140, isDestroyed: vi.fn(() => false), @@ -199,14 +199,186 @@ describe('browserManager', () => { features: string disposition: string }) => { action: 'allow' | 'deny' } + const openerDependentOpens = [ + { url: 'https://sso.example.com/auth', frameName: 'ssoWindow', features: '' }, + { url: 'https://sso.example.com/auth', frameName: '', features: 'width=500,height=600' }, + { url: 'about:blank', frameName: '', features: '' } + ] for (const disposition of ['foreground-tab', 'background-tab', 'new-window']) { - expect( - handler({ url: 'https://sso.example.com/auth', frameName: '', features: '', disposition }) - ).toMatchObject({ action: 'allow' }) + for (const open of openerDependentOpens) { + expect(handler({ ...open, disposition })).toMatchObject({ action: 'allow' }) + } } expect(shellOpenExternalMock).not.toHaveBeenCalled() }) + it('routes unnamed featureless window.open safely, including after renderer destruction', () => { + const rendererSendMock = vi.fn() + let rendererDestroyed = false + const guest = { + id: 142, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => rendererDestroyed), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + frameName: string + features: string + disposition: string + }) => { action: 'allow' | 'deny' } + for (const disposition of ['foreground-tab', 'background-tab']) { + expect( + handler({ + url: 'https://docs.example.com/guide', + frameName: '', + features: '', + disposition + }) + ).toEqual({ action: 'deny' }) + } + + expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + browserPageId: 'browser-1', + url: 'https://docs.example.com/guide' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'https://docs.example.com', + action: 'opened-in-orca' + }) + expect(openPopupWithOriginBarMock).not.toHaveBeenCalled() + expect(shellOpenExternalMock).not.toHaveBeenCalled() + rendererDestroyed = true + expect( + handler({ + url: 'https://docs.example.com/guide', + frameName: '', + features: '', + disposition: 'foreground-tab' + }) + ).toEqual({ action: 'deny' }) + expect(openPopupWithOriginBarMock).not.toHaveBeenCalled() + expect(shellOpenExternalMock).not.toHaveBeenCalled() + }) + + it('shares the page-initiated tab budget across the whole opener popup tree', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 150, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + once: vi.fn(), + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + const guestsById = new Map([[guest.id, guest]]) + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return guestsById.get(id) ?? null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + type WindowOpenHandler = (details: { + url: string + frameName: string + features: string + disposition: string + }) => { + action: 'allow' | 'deny' + createWindow?: (options: Record) => unknown + } + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as WindowOpenHandler + + // A named child popup keeps a real child window; each one used to start with a fresh budget. + const openNamedChild = (index: number): WindowOpenHandler => { + const childSetWindowOpenHandlerMock = vi.fn() + const child = { + id: 1500 + index, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: childSetWindowOpenHandlerMock, + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + openDevTools: vi.fn() + } + guestsById.set(child.id, child) + openPopupWithOriginBarMock.mockReturnValueOnce({ + contentWebContents: child, + close: vi.fn(), + onClosed: vi.fn() + }) + const result = handler({ + url: `https://sso.example.com/child-${index}`, + frameName: `child-${index}`, + features: '', + disposition: 'new-window' + }) + expect(result).toMatchObject({ action: 'allow' }) + result.createWindow?.({}) + return childSetWindowOpenHandlerMock.mock.calls[0][0] as WindowOpenHandler + } + + const childHandlers = [openNamedChild(0), openNamedChild(1), openNamedChild(2)] + for (const [childIndex, childHandler] of childHandlers.entries()) { + for (let openIndex = 0; openIndex < MAX_PAGE_INITIATED_TABS_PER_WINDOW; openIndex++) { + expect( + childHandler({ + url: `https://docs.example.com/${childIndex}-${openIndex}`, + frameName: '', + features: '', + disposition: 'foreground-tab' + }) + ).toEqual({ action: 'deny' }) + } + } + + const routedTabs = rendererSendMock.mock.calls.filter( + ([channel]) => channel === 'browser:open-link-in-orca-tab' + ) + expect(routedTabs).toHaveLength(MAX_PAGE_INITIATED_TABS_PER_WINDOW) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'https://docs.example.com', + action: 'blocked' + }) + expect(openPopupWithOriginBarMock).toHaveBeenCalledTimes(childHandlers.length) + }) + it('keeps plain links current and routes explicit new-tab gestures to Orca tabs', async () => { const rendererSendMock = vi.fn() const executeJavaScriptInIsolatedWorldMock = vi.fn().mockResolvedValue(undefined) @@ -589,197 +761,4 @@ describe('browserManager', () => { }) ) }) - - it('attaches guest policies to created popup child windows', () => { - const rendererSendMock = vi.fn() - const childSetBackgroundThrottlingMock = vi.fn() - const childSetWindowOpenHandlerMock = vi.fn() - const childOnMock = vi.fn() - const childOffMock = vi.fn() - const childOpenDevToolsMock = vi.fn() - const childGuest = { - id: 4040, - isDestroyed: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: childSetBackgroundThrottlingMock, - setWindowOpenHandler: childSetWindowOpenHandlerMock, - on: childOnMock, - off: childOffMock, - openDevTools: childOpenDevToolsMock - } - const guest = { - id: 404, - isDestroyed: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - setWindowOpenHandler: guestSetWindowOpenHandlerMock, - on: guestOnMock, - off: guestOffMock, - openDevTools: guestOpenDevToolsMock - } - webContentsFromIdMock.mockImplementation((id: number) => { - if (id === guest.id) { - return guest - } - if (id === rendererWebContentsId) { - return { isDestroyed: vi.fn(() => false), send: rendererSendMock } - } - return null - }) - - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'browser-1', - webContentsId: guest.id, - rendererWebContentsId - }) - - const didCreateWindowHandler = guestOnMock.mock.calls.find( - ([event]) => event === 'did-create-window' - )?.[1] as ((window: { webContents: typeof childGuest }) => void) | undefined - expect(didCreateWindowHandler).toBeTypeOf('function') - - didCreateWindowHandler?.({ webContents: childGuest }) - - expect(childSetBackgroundThrottlingMock).toHaveBeenCalledWith(false) - expect(childSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1) - expect(childOnMock.mock.calls.filter(([event]) => event === 'did-create-window')).toHaveLength( - 1 - ) - expect(childOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1) - expect(childOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1) - - const childWindowOpenHandler = childSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { - url: string - }) => { action: 'allow' | 'deny' } - expect(childWindowOpenHandler({ url: 'https://identity.example.com/login' })).toMatchObject({ - action: 'allow' - }) - expect(childWindowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) - expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { - browserPageId: 'browser-1', - origin: 'null', - action: 'blocked' - }) - browserManager.notifyPermissionDenied({ - guestWebContentsId: childGuest.id, - permission: 'notifications', - rawUrl: 'https://identity.example.com/login' - }) - expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', { - browserPageId: 'browser-1', - permission: 'notifications', - origin: 'https://identity.example.com' - }) - - const childDidFailLoadHandler = childOnMock.mock.calls.find( - ([event]) => event === 'did-fail-load' - )?.[1] as - | (( - event: Electron.Event, - errorCode: number, - errorDescription: string, - validatedURL: string, - isMainFrame: boolean - ) => void) - | undefined - childDidFailLoadHandler?.( - {} as Electron.Event, - -105, - 'Name not resolved', - 'https://identity.example.com/unavailable', - true - ) - expect(rendererSendMock).not.toHaveBeenCalledWith( - 'browser:guest-load-failed', - expect.anything() - ) - - const childDownloadItem = createDownloadItem() - browserManager.handleGuestWillDownload({ - guestWebContentsId: childGuest.id, - item: childDownloadItem - }) - expect(rendererSendMock).toHaveBeenCalledWith( - 'browser:download-requested', - expect.objectContaining({ browserPageId: 'browser-1' }) - ) - const childDownloadDoneHandler = getDownloadItemEventHandler(childDownloadItem, 'once', 'done') - childDownloadDoneHandler?.({} as Electron.Event, 'completed') - - const managerState = browserManager as unknown as { - popupOwnerContextByGuestId: Map - } - expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(true) - - const cleanupChildOnMock = vi.fn() - const cleanupChildGuest = { - ...childGuest, - id: 4041, - on: cleanupChildOnMock, - off: vi.fn(), - setBackgroundThrottling: vi.fn(), - setWindowOpenHandler: vi.fn() - } - const childDidCreateWindowHandler = childOnMock.mock.calls.find( - ([event]) => event === 'did-create-window' - )?.[1] as ((window: { webContents: typeof cleanupChildGuest }) => void) | undefined - childDidCreateWindowHandler?.({ webContents: cleanupChildGuest }) - expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(true) - const cleanupChildWindowOpenHandler = cleanupChildGuest.setWindowOpenHandler.mock - .calls[0][0] as (details: { url: string }) => { action: 'allow' | 'deny' } - expect( - cleanupChildWindowOpenHandler({ url: 'https://identity.example.com/continue' }) - ).toMatchObject({ action: 'allow' }) - const cleanupChildDestroyedHandler = cleanupChildOnMock.mock.calls.find( - ([event]) => event === 'destroyed' - )?.[1] as (() => void) | undefined - cleanupChildDestroyedHandler?.() - expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(false) - - const replacementGuest = { - ...guest, - id: 405, - on: vi.fn(), - off: vi.fn(), - setBackgroundThrottling: vi.fn(), - setWindowOpenHandler: vi.fn() - } - webContentsFromIdMock.mockImplementation((id: number) => { - if (id === guest.id) { - return guest - } - if (id === replacementGuest.id) { - return replacementGuest - } - if (id === rendererWebContentsId) { - return { isDestroyed: vi.fn(() => false), send: rendererSendMock } - } - return null - }) - browserManager.attachGuestPolicies(replacementGuest as never) - browserManager.registerGuest({ - browserPageId: 'browser-1', - webContentsId: replacementGuest.id, - rendererWebContentsId - }) - - expect(childWindowOpenHandler({ url: 'https://identity.example.com/next' })).toEqual({ - action: 'deny' - }) - expect(shellOpenExternalMock).toHaveBeenCalledWith('https://identity.example.com/next') - expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) - - const childDestroyedHandler = childOnMock.mock.calls.find( - ([event]) => event === 'destroyed' - )?.[1] as (() => void) | undefined - childDestroyedHandler?.() - expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) - - browserManager.unregisterAll() - - expect(childOffMock).toHaveBeenCalledWith('did-create-window', expect.any(Function)) - expect(childOffMock).toHaveBeenCalledWith('will-navigate', expect.any(Function)) - expect(childOffMock).toHaveBeenCalledWith('will-redirect', expect.any(Function)) - }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 1e04cbdf0cc..7ac64b485b9 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -43,6 +43,11 @@ import { buildBrowserClickedLinkRoutingScript, buildBrowserIframeClickedLinkRoutingScript } from './browser-clicked-link-routing' +import { + createPageInitiatedTabBudget, + type PageInitiatedTabBudget +} from './browser-page-initiated-tab-budget' +import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' import { cleanElectronUserAgent } from './browser-session-ua' import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' @@ -233,6 +238,8 @@ export class BrowserManager { // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. private readonly tabIdByWebContentsId = new Map() private readonly popupOwnerContextByGuestId = new Map() + // Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota. + private readonly pageInitiatedTabBudgetByRootGuestId = new Map() // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. private readonly workspaceIdByPageId = new Map() private readonly sessionProfileIdByPageId = new Map() @@ -394,6 +401,16 @@ export class BrowserManager { return null } + /** Shared across the whole opener tree, so a chain of popups draws from one budget. */ + private tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean { + let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId) + if (!budget) { + budget = createPageInitiatedTabBudget() + this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget) + } + return budget.tryConsume(Date.now()) + } + private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) if (!rendererWebContentsId) { @@ -758,8 +775,9 @@ export class BrowserManager { this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) } guest.on('did-create-window', handleDidCreateWindow) - guest.setWindowOpenHandler(({ url, frameName }) => { - const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) + guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => { + const ownerContext = this.resolvePopupOwnerContext(guest.id) + const browserTabId = ownerContext?.browserTabId ?? null const browserUrl = normalizeBrowserNavigationUrl(url) const externalUrl = normalizeExternalBrowserUrl(url) const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) @@ -784,6 +802,33 @@ export class BrowserManager { return { action: 'deny' } } + // Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is + // the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are + // excluded by isNewBrowserTabPopupIntent and still get a real child window below. + if ( + ownerContext && + externalUrl && + isNewBrowserTabPopupIntent({ frameName, disposition, features }) + ) { + // Why: one activation lets a page loop window.open, and each routed tab persists into + // workspace session state, so it survives the quit that used to clear popup windows. + if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'blocked' + }) + return { action: 'deny' } + } + if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open. + return { action: 'deny' } + } + // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) if (browserTabId && canOpenAsChild) { @@ -1243,6 +1288,7 @@ export class BrowserManager { this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) + this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) this.pendingNavigationByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. @@ -1444,6 +1490,7 @@ export class BrowserManager { this.clickedLinkFrameNameByGuestId.clear() this.tabIdByWebContentsId.clear() this.popupOwnerContextByGuestId.clear() + this.pageInitiatedTabBudgetByRootGuestId.clear() this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() this.userAgentModeByPageId.clear() diff --git a/src/main/browser/browser-page-initiated-tab-budget.test.ts b/src/main/browser/browser-page-initiated-tab-budget.test.ts new file mode 100644 index 00000000000..c2dc7618a7f --- /dev/null +++ b/src/main/browser/browser-page-initiated-tab-budget.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { + createPageInitiatedTabBudget, + MAX_PAGE_INITIATED_TABS_PER_WINDOW, + PAGE_INITIATED_TAB_WINDOW_MS +} from './browser-page-initiated-tab-budget' + +describe('page-initiated tab budget', () => { + it('absorbs a window.open loop fired from a single activation', () => { + const budget = createPageInitiatedTabBudget() + const granted = Array.from({ length: 12 }, () => budget.tryConsume(1_000)).filter(Boolean) + + expect(granted).toHaveLength(MAX_PAGE_INITIATED_TABS_PER_WINDOW) + }) + + it('refills once the rolling window has passed, so real browsing is unaffected', () => { + const budget = createPageInitiatedTabBudget() + for (let i = 0; i < MAX_PAGE_INITIATED_TABS_PER_WINDOW; i++) { + expect(budget.tryConsume(1_000)).toBe(true) + } + expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS - 1)).toBe(false) + expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS)).toBe(true) + }) + + it('slides rather than resetting, so a paced flood cannot outrun the cap', () => { + const budget = createPageInitiatedTabBudget(2, 1_000) + + expect(budget.tryConsume(0)).toBe(true) + expect(budget.tryConsume(900)).toBe(true) + // The first grant has aged out by 1_000 but the second has not. + expect(budget.tryConsume(1_000)).toBe(true) + expect(budget.tryConsume(1_100)).toBe(false) + }) +}) diff --git a/src/main/browser/browser-page-initiated-tab-budget.ts b/src/main/browser/browser-page-initiated-tab-budget.ts new file mode 100644 index 00000000000..d15a5c9ba4c --- /dev/null +++ b/src/main/browser/browser-page-initiated-tab-budget.ts @@ -0,0 +1,26 @@ +/** Bounded so one hostile click cannot plant an unbounded number of tabs. */ +export const MAX_PAGE_INITIATED_TABS_PER_WINDOW = 4 +export const PAGE_INITIATED_TAB_WINDOW_MS = 2_000 + +export type PageInitiatedTabBudget = { + /** Records the grant when it returns true; call only when the tab is actually being opened. */ + tryConsume: (now: number) => boolean +} + +/** Rolling window, so it absorbs a same-tick `window.open` loop without capping real browsing. */ +export function createPageInitiatedTabBudget( + maxPerWindow = MAX_PAGE_INITIATED_TABS_PER_WINDOW, + windowMs = PAGE_INITIATED_TAB_WINDOW_MS +): PageInitiatedTabBudget { + let grants: number[] = [] + return { + tryConsume: (now) => { + grants = grants.filter((grantedAt) => now - grantedAt < windowMs) + if (grants.length >= maxPerWindow) { + return false + } + grants.push(now) + return true + } + } +} diff --git a/src/main/browser/browser-popup-new-tab-intent.ts b/src/main/browser/browser-popup-new-tab-intent.ts new file mode 100644 index 00000000000..316614b4aab --- /dev/null +++ b/src/main/browser/browser-popup-new-tab-intent.ts @@ -0,0 +1,17 @@ +/** + * Whether a `window.open()` asks for a new tab rather than a popup window. + * + * Orca answers a tab by denying, which hands the page `null`, so named and featured opens — whose + * flow may use that handle — stay popups. + */ +export function isNewBrowserTabPopupIntent(details: { + frameName: string + disposition: string + features: string +}): boolean { + return ( + details.frameName === '' && + details.features.trim() === '' && + (details.disposition === 'foreground-tab' || details.disposition === 'background-tab') + ) +} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts index 15a632a25e2..4f3450dae85 100644 --- a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts @@ -89,7 +89,20 @@ export function registerBrowserStateIpcBridge( if (!sourcePage || getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { return } - store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) + // Why: the link inherits the opener's cookie jar. Falling back to the default profile would let + // a page in an isolated session hand its links to the default one, silently crossing profiles. + const sourceTab = (store.browserTabsByWorktree[sourcePage.worktreeId] ?? []).find( + (tab) => tab.id === sourcePage.workspaceId + ) + store.createBrowserTab(sourcePage.worktreeId, url, { + title: url, + ...(sourceTab + ? { + sessionProfileId: sourceTab.sessionProfileId, + sessionPartition: sourceTab.sessionPartition + } + : {}) + }) }) ) } diff --git a/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts b/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts new file mode 100644 index 00000000000..b15a5be158c --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { createBrowserTabMock, storeState } = vi.hoisted(() => ({ + createBrowserTabMock: vi.fn(), + storeState: { + value: {} as Record + } +})) + +vi.mock('../../store', () => ({ + useAppStore: { getState: () => storeState.value } +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null +})) +vi.mock('@/components/browser-pane/describe-page/live-browser-url-registry', () => ({ + rememberLiveBrowserUrl: vi.fn() +})) +vi.mock('./browser-automation-bootstrap-lease', () => ({ + acquireBrowserAutomationBootstrapLease: vi.fn() +})) + +import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' + +const noopUnsubscribe = (): void => {} + +function captureOpenLinkHandler(): (event: { browserPageId: string; url: string }) => void { + let handler: ((event: { browserPageId: string; url: string }) => void) | null = null + const browserApi = new Proxy( + { + onOpenLinkInOrcaTab: (callback: (event: { browserPageId: string; url: string }) => void) => { + handler = callback + return noopUnsubscribe + } + } as Record, + { + get: (target, property) => + property in target ? target[property as string] : () => noopUnsubscribe + } + ) + const api = new Proxy({ browser: browserApi } as Record, { + get: (target, property) => + property in target + ? target[property as string] + : new Proxy({}, { get: () => () => noopUnsubscribe }) + }) + ;(globalThis as { window?: unknown }).window = { api } + + registerBrowserStateIpcBridge([], () => false) + if (!handler) { + throw new Error('Expected the bridge to subscribe to browser:open-link-in-orca-tab') + } + return handler +} + +describe('link-opened Orca tabs', () => { + beforeEach(() => { + createBrowserTabMock.mockReset() + }) + + it('inherits the opener tab session so an isolated profile cannot leak into the default one', () => { + storeState.value = { + browserPagesByWorkspace: { + 'workspace-1': [{ id: 'page-1', workspaceId: 'workspace-1', worktreeId: 'worktree-1' }] + }, + browserTabsByWorktree: { + 'worktree-1': [ + { + id: 'workspace-1', + sessionProfileId: 'profile-client-a', + sessionPartition: 'persist:orca-browser-session-client-a' + } + ] + }, + createBrowserTab: createBrowserTabMock + } + + captureOpenLinkHandler()({ browserPageId: 'page-1', url: 'https://docs.example.com/guide' }) + + expect(createBrowserTabMock).toHaveBeenCalledWith( + 'worktree-1', + 'https://docs.example.com/guide', + expect.objectContaining({ + sessionProfileId: 'profile-client-a', + sessionPartition: 'persist:orca-browser-session-client-a' + }) + ) + }) + + it('leaves the profile unset when the opener tab is gone, so the user default still applies', () => { + storeState.value = { + browserPagesByWorkspace: { + 'workspace-1': [{ id: 'page-1', workspaceId: 'missing', worktreeId: 'worktree-1' }] + }, + browserTabsByWorktree: {}, + createBrowserTab: createBrowserTabMock + } + + captureOpenLinkHandler()({ browserPageId: 'page-1', url: 'https://docs.example.com/guide' }) + + const options = createBrowserTabMock.mock.calls[0][2] as Record + expect('sessionProfileId' in options).toBe(false) + }) +}) diff --git a/tests/e2e/browser-tab.spec.ts b/tests/e2e/browser-tab.spec.ts index 4988b76ccc2..00075b4af19 100644 --- a/tests/e2e/browser-tab.spec.ts +++ b/tests/e2e/browser-tab.spec.ts @@ -680,7 +680,7 @@ test.describe('Browser Tab', () => { } }) - test('plain links stay current while explicit new-tab gestures activate Orca tabs', async ({ + test('every new-tab link gesture activates an Orca tab and never a native window', async ({ electronApp, orcaPage }) => { @@ -698,23 +698,20 @@ test.describe('Browser Tab', () => { const baseWindowCount = await electronApp.evaluate( ({ BaseWindow }) => BaseWindow.getAllWindows().length ) - const baseTabCount = await orcaPage.locator('[data-tab-id]').count() - await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link') - + // A plain target=_blank click is a new-tab request, in the main frame and in an iframe; + // the source tab must stay put rather than navigate away under it. const sourceTabLocator = orcaPage.locator(`[data-tab-id="${sourceTab!.id}"]`) - await expect(sourceTabLocator).toContainText('Linked destination', { timeout: 10_000 }) - await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(baseTabCount) + await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link') + await expectBrowserTabActive(orcaPage, 'Linked destination') + await expect(sourceTabLocator).toContainText('Source page') + await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) - await clickBrowserLink(orcaPage, sourceTab!.id, '#return-link') - await expect(sourceTabLocator).toContainText('Source page', { timeout: 10_000 }) await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-link', { frameSelector: '#link-frame' }) - await expect(sourceTabLocator).toContainText('Frame destination', { timeout: 10_000 }) - await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(baseTabCount) - - await clickBrowserLink(orcaPage, sourceTab!.id, '#return-link') - await expect(sourceTabLocator).toContainText('Source page', { timeout: 10_000 }) + await expectBrowserTabActive(orcaPage, 'Frame destination') + await expect(sourceTabLocator).toContainText('Source page') + await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-modifier-link', { frameSelector: '#link-frame', From 92536346bd414d37afeadb5b69c2171b1179cbd3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:08:07 -0700 Subject: [PATCH 020/118] Add terminal unavailability exports to parity test (#16810) New runtime exports for handling terminal unavailability: RuntimeTerminalUnavailableReason type and related error codes and messaging constants. --- src/shared/runtime-client-export-parity.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/shared/runtime-client-export-parity.test.ts b/src/shared/runtime-client-export-parity.test.ts index ad3d9708eec..2b86d2e6e99 100644 --- a/src/shared/runtime-client-export-parity.test.ts +++ b/src/shared/runtime-client-export-parity.test.ts @@ -158,6 +158,7 @@ type RuntimeTypeInventory = [ Runtime.RuntimeTerminalSplit, Runtime.RuntimeTerminalState, Runtime.RuntimeTerminalSummary, + Runtime.RuntimeTerminalUnavailableReason, Runtime.RuntimeTerminalVisualGroupNode, Runtime.RuntimeTerminalVisualLayout, Runtime.RuntimeTerminalVisualLayoutNode, @@ -225,8 +226,11 @@ describe('runtime client public export parity', () => { 'BROWSER_UNAVAILABLE_ERROR_CODE', 'COMPUTER_ERROR_CODES', 'HEADLESS_RUNTIME_WINDOW_ID', + 'TERMINAL_PTY_DEGRADATION_CAPABILITY', + 'TERMINAL_UNAVAILABLE_ERROR_CODE', 'UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH', - 'browserUnavailableMessage' + 'browserUnavailableMessage', + 'terminalUnavailableMessage' ]) expect(Object.keys(RemoteClient).sort()).toEqual([ 'RemoteRuntimeClientError', From 642607bfa711d46fe14f3b376583fba54ea9135c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:45:56 -0700 Subject: [PATCH 021/118] feat(diagnostics): name the code driving a React commit cascade (#16730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(diagnostics): name the code driving a React commit cascade React #185 reports blame whichever component dispatched after the root-global counter tripped. react-update-depth-attribution already tells the report that boundary_id names a bystander; nothing recorded what the real driver was. Count commits through react-dom's devtools commit hook — the only per-commit seam that survives minification. Profiler's onRender is compiled out of the production bundle, and a dependency-less root layout effect fires per render of its own component, not per commit (measured: a root effect saw 1 of 11 commits a leaf drove). Mirror React's own reset rule rather than a time window: a commit that leaves no sync lanes pending ends the cascade, and a different root restarts it. The steady-state cost is a mask, a compare and an increment, with no clock read and no allocation. Stack sampling arms only once a cascade is already deep, so ordinary work never pays for it. * fix(diagnostics): remove the install-order trap and guard the write path Adversarial and perf review of the cascade diagnostic: The install-order ratchet guarded the wrong thing. The observer self-installs at the bottom of its own module, so it only ran after its transitive graph evaluated — one new import reaching react-dom would have killed the diagnostic in production with every test green. The entries now import the import-free shim instead, which only has to make the global exist; wrapping the callback is timing-independent because react-dom re-reads it per commit. The store write probe called the sampler unguarded, so a throw there dropped the write on the app's universal write path. Guarded; the try/catch measured free at +0.005ns. Report the frames that name the driver instead of capturing eight and reporting one, arm the self-check on the paths where install fails, bind the sample cap to the write count rather than a V8-only API, and stop defining the devtools global for every test file to serve one. The cascadeRoot comment claimed a strong reference cannot retain; a WeakRef probe disproved it. It is still not a leak — the next non-cascading commit clears the slot — so the comment now says that instead. * test(diagnostics): close the ratchet holes guarding the cascade hook Adversarial review loop 2: The install-order ratchet only saw imports whose `from` shared a line with the keyword, so a multi-line `import { createRoot } from 'react-dom/client'` in the shim passed it — and that is the one edit that kills the diagnostic in production. 43% of files in this directory use the multi-line form. Scan the shim source directly as well as walking the graph. The 4000-char budget for the driver frames is bought by the key ending in `stack`, but the only test asserting that emitted its own literal key, so renaming the real one truncated the frames with the suite green. Assert the name the renderer actually emits. Also correct the comment on the `installed` placement: the self-check never reads that flag, it arms because it sits outside the try. * test(diagnostics): stop the shim ratchet firing on prose Adversarial review loop 3 caught two flaws in the guards added last commit. The source-scan regex used an unbounded `[\s\S]*?` after an anchor that also matched the shim's own `export type`, so it degenerated to "does the word `from` appear later in the file" — rewriting a doc comment to say "reads the hook from the global" failed the ratchet. A guard that fails on prose is a guard someone deletes, and this one is what stands between a reshuffled import and a silently dead diagnostic. Require a quote after `from`, tolerate comment obfuscation, and catch `await import(...)`, which makes the shim async so react-dom evaluates before the hook is installed. The 4000-char budget assertion matched `/stack$/i` against the raw key, but the real rule camel-splits first — so `driverstack` would pass while shipping truncated frames. Assert through sanitizeCrashReportDetails, resolving the key from the payload rather than hard-coding it. --- ...ash-reporting-renderer-breadcrumbs.test.ts | 75 ++++ .../crash-reporting-renderer-breadcrumbs.ts | 9 + .../terminal-park-verdict-flip-telemetry.ts | 3 +- ...react-commit-cascade-install-order.test.ts | 137 +++++++ ...-commit-cascade-observer.react185.test.tsx | 169 +++++++++ .../lib/react-commit-cascade-observer.test.ts | 307 ++++++++++++++++ .../src/lib/react-commit-cascade-observer.ts | 101 ++++++ ...commit-cascade-store-write-samples.test.ts | 144 ++++++++ ...eact-commit-cascade-store-write-samples.ts | 157 ++++++++ .../react-commit-cascade-telemetry.test.ts | 339 ++++++++++++++++++ .../src/lib/react-commit-cascade-telemetry.ts | 205 +++++++++++ .../lib/react-devtools-commit-hook-shim.ts | 70 ++++ src/renderer/src/main.tsx | 6 + src/renderer/src/popout.tsx | 8 + src/renderer/src/store/index.ts | 101 +++--- .../react-commit-cascade-write-probe.test.ts | 73 ++++ .../store/react-commit-cascade-write-probe.ts | 37 ++ src/shared/react-update-depth-attribution.ts | 7 + 18 files changed, 1897 insertions(+), 51 deletions(-) create mode 100644 src/renderer/src/lib/react-commit-cascade-install-order.test.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx create mode 100644 src/renderer/src/lib/react-commit-cascade-observer.test.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-observer.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-store-write-samples.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-telemetry.test.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-telemetry.ts create mode 100644 src/renderer/src/lib/react-devtools-commit-hook-shim.ts create mode 100644 src/renderer/src/store/react-commit-cascade-write-probe.test.ts create mode 100644 src/renderer/src/store/react-commit-cascade-write-probe.ts diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index 41c108a5cbc..5568030c17a 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -393,6 +393,81 @@ describe('renderer breadcrumb IPC routing', () => { ]) }) + // Why: a #185 crash loop emits a cascade notice per cascade, back to back, + // and uncoalesced they would flush the 30-entry ring holding the trail. + it('coalesces react commit cascade notices from one driver into one slot', () => { + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 40, rendererSurface: 'main', driverFrame: 'tabs.ts:12:3 bump' } + }) + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 41, rendererSurface: 'main', driverFrame: 'tabs.ts:12:3 bump' } + }) + + expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() + expect( + recordCoalescedCrashBreadcrumbMock.mock.calls.map( + (call) => (call[0] as { coalesceKey: string }).coalesceKey + ) + ).toEqual([ + 'react_commit_cascade:main:tabs.ts:12:3 bump', + 'react_commit_cascade:main:tabs.ts:12:3 bump' + ]) + }) + + // Why keyed and not name-only: coalescing keeps only the newest payload, so + // one surface or driver would erase the other's evidence. + it('keeps different cascade surfaces and drivers in separate slots', () => { + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 40, rendererSurface: 'main', driverFrame: 'tabs.ts:12:3 bump' } + }) + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 40, rendererSurface: 'dashboard-popout', driverFrame: 'tabs.ts:12:3 bump' } + }) + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 40, rendererSurface: 'main', storeWrites: 0 } + }) + + expect( + new Set( + recordCoalescedCrashBreadcrumbMock.mock.calls.map( + (call) => (call[0] as { coalesceKey: string }).coalesceKey + ) + ).size + ).toBe(3) + }) + + // Why asserted here: the key ends in `stack`, which is what buys the driver + // frames the 4000-char budget instead of the 240-char one details get. + it('keeps the full driver stack on a react commit cascade notice', () => { + const driverStack = `tabs.ts:12:3 bump\n${'tabs.ts:99:1 other\n'.repeat(60)}` + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 40, rendererSurface: 'main', driverFrame: 'tabs.ts:12:3 bump', driverStack } + }) + + const recorded = recordCoalescedCrashBreadcrumbMock.mock.calls[0]?.[0] as + | { data: { driverStack: string } } + | undefined + expect(recorded?.data.driverStack.length).toBeGreaterThan(240) + }) + + // Why uncoalesced: it fires at most once per renderer, and it is the signal + // that the diagnostic itself never wired up. + it('records the uninstalled self-check notice directly', () => { + emitRendererBreadcrumb({ name: 'react_commit_cascade_uninstalled' }) + + expect(recordCrashBreadcrumbMock).toHaveBeenCalledWith( + 'react_commit_cascade_uninstalled', + undefined + ) + expect(recordCoalescedCrashBreadcrumbMock).not.toHaveBeenCalled() + }) + it('records non-error renderer breadcrumbs without coalescing', () => { emitRendererBreadcrumb({ name: 'renderer_bootstrap_started', data: { dev: true } }) diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts index 2b2129436fb..160cb93dd87 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts @@ -48,12 +48,14 @@ function recordRendererBreadcrumbTrace( // suppressed count instead. const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees' const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn' +const REACT_COMMIT_CASCADE_BREADCRUMB = 'react_commit_cascade' const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ 'renderer_error', 'renderer_unhandled_rejection', 'terminal_safe_fit_retry_exhausted', DUPLICATE_TAB_OWNER_BREADCRUMB, PARK_VERDICT_CHURN_BREADCRUMB, + REACT_COMMIT_CASCADE_BREADCRUMB, TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB ]) const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 @@ -82,6 +84,13 @@ function rendererBreadcrumbCoalesceKey( if (name === PARK_VERDICT_CHURN_BREADCRUMB) { return `${name}:${String(data?.trigger ?? '')}` } + // Why keyed on surface and driver frame: the popout and the main window can + // cascade independently, and two different driving writes are two different + // bugs that last-write coalescing would collapse into one. A driverless crumb + // (storeWrites 0, a useState loop) keys separately for the same reason. + if (name === REACT_COMMIT_CASCADE_BREADCRUMB) { + return `${name}:${String(data?.rendererSurface ?? '')}:${String(data?.driverFrame ?? '')}` + } // Preserve distinct GPU failures and atlas-reset triggers while coalescing each storm. if (name === TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB) { const kind = String(data?.kind ?? '') diff --git a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts index 17b53d53785..13642519029 100644 --- a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts @@ -10,13 +10,12 @@ * one tab as "damping did not reach the oscillating input". */ import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { REACT_NESTED_UPDATE_LIMIT } from '../../../../shared/react-update-depth-attribution' export const TERMINAL_TAB_PARK_FLIP_WINDOW_MS = 60_000 /** Flips per window that no sane park policy should reach. Breadcrumb only. */ export const TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT = 12 -/** react-dom 19.2.x nested commit limit. */ -const REACT_NESTED_UPDATE_LIMIT = 50 /** Measured upper bound after the passive-effect pin engages. */ const PARK_PIN_SETTLE_COMMITS = 6 /** Worst-case commits from pane, watcher, and store work per verdict flip. */ diff --git a/src/renderer/src/lib/react-commit-cascade-install-order.test.ts b/src/renderer/src/lib/react-commit-cascade-install-order.test.ts new file mode 100644 index 00000000000..2f5a1227e89 --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-install-order.test.ts @@ -0,0 +1,137 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve, dirname } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * react-dom reads __REACT_DEVTOOLS_GLOBAL_HOOK__ once, at its own module + * evaluation, and enters the graph through several component modules rather + * than just the entry's import of react-renderer-root. ES evaluation is + * depth-first in source order, so only the FIRST import statement is guaranteed + * to beat it — and only if that module's own graph cannot reach react-dom + * first, which is why the shim has no imports at all. + * + * Nothing else fails when either property stops holding: the diagnostic simply + * records nothing. These tests are the failure. + */ +const RENDERER_ROOT = resolve(__dirname, '..') +const SHIM_MODULE = 'react-devtools-commit-hook-shim' +const SHIM_IMPORT = `import './lib/${SHIM_MODULE}'` +const OBSERVER_IMPORT = "import './lib/react-commit-cascade-observer'" + +/** Entries whose crash reports reach the breadcrumb pipe. */ +const INSTRUMENTED_ENTRIES = ['main.tsx', 'popout.tsx'] +/** + * The web preload stubs crashReports.recordBreadcrumb to a no-op + * (src/renderer/src/web/preload-api/web-diagnostics-api.ts), so instrumenting + * the web entry would cost commits and record nothing. + */ +const UNINSTRUMENTED_ENTRIES = [join('web', 'main.tsx')] + +function firstImportStatement(source: string): string | undefined { + return source + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('import ')) +} + +function importedSpecifiers(source: string): string[] { + return Array.from(source.matchAll(/(?:^|\n)\s*(?:import|export)[^\n]*?from\s*'([^']+)'/g)) + .map((match) => match[1] as string) + .concat( + Array.from(source.matchAll(/(?:^|\n)\s*import\s*'([^']+)'/g)).map( + (match) => match[1] as string + ) + ) +} + +/** Mirrors the alias in config/vitest.config.ts and electron.vite.config.ts. */ +function resolveLocalModule(specifier: string, fromFile: string): string | undefined { + const base = specifier.startsWith('@/') + ? join(RENDERER_ROOT, specifier.slice(2)) + : specifier.startsWith('.') + ? join(dirname(fromFile), specifier) + : undefined + if (base === undefined) { + return undefined + } + // Only TS: a .css or asset import cannot pull react-dom into the graph. + return [`${base}.ts`, `${base}.tsx`, join(base, 'index.ts')].find((candidate) => + existsSync(candidate) + ) +} + +/** Bare specifiers stay as-is; local ones are followed so a hop cannot hide react-dom. */ +function transitiveSpecifiers(entryFile: string): Set { + const seen = new Set() + const specifiers = new Set() + const queue = [entryFile] + while (queue.length > 0) { + const file = queue.pop() as string + if (seen.has(file)) { + continue + } + seen.add(file) + for (const specifier of importedSpecifiers(readFileSync(file, 'utf8'))) { + specifiers.add(specifier) + const local = resolveLocalModule(specifier, file) + if (local) { + queue.push(local) + } + } + } + return specifiers +} + +describe('react commit cascade shim install order', () => { + for (const entry of INSTRUMENTED_ENTRIES) { + it(`keeps the import-free shim as the first import in ${entry}`, () => { + const source = readFileSync(join(RENDERER_ROOT, entry), 'utf8') + + expect(source).toContain(SHIM_IMPORT) + expect(firstImportStatement(source)).toBe(SHIM_IMPORT) + }) + + // Why separate: the observer wraps a property react-dom re-reads per commit, + // so it only has to be in the graph, not first in it. + it(`still installs the observer in ${entry}`, () => { + expect(readFileSync(join(RENDERER_ROOT, entry), 'utf8')).toContain(OBSERVER_IMPORT) + }) + } + + for (const entry of UNINSTRUMENTED_ENTRIES) { + it(`leaves ${entry} uninstrumented, where breadcrumbs are a no-op`, () => { + const source = readFileSync(join(RENDERER_ROOT, entry), 'utf8') + + expect(source).not.toContain('react-commit-cascade-observer') + }) + } + + // Why this is the real ratchet: one import here that transitively reaches + // react-dom would let react-dom evaluate first and read no hook, killing the + // diagnostic in production with every other test still green. + it('keeps the shim free of imports, react-dom above all', () => { + const shimFile = join(RENDERER_ROOT, 'lib', `${SHIM_MODULE}.ts`) + + const graph = transitiveSpecifiers(shimFile) + expect(Array.from(graph).filter((specifier) => specifier.includes('react-dom'))).toEqual([]) + expect(Array.from(graph)).toEqual([]) + // Why a source scan too: the specifier regex only sees an import whose `from` + // shares a line with the keyword, and a multi-line import is the common form + // here — so the graph check alone would miss the one edit that matters. + // Why `from` must be followed by a quote: bare `\bfrom\b` also matched the + // word in a comment, and a ratchet that fails on prose gets deleted. + // Why the `import(` arm: a top-level `await import(...)` makes this module + // async, so react-dom evaluates before the hook is installed. + expect(readFileSync(shimFile, 'utf8')).not.toMatch( + /^\s*(?:import|export)\b[\s\S]*?\bfrom\s*(?:\/\*[\s\S]*?\*\/\s*)*['"]|^\s*import\s*['"]|\bimport\s*\(/m + ) + }) + + it('follows local hops when checking a graph for react-dom', () => { + const observerFile = join(RENDERER_ROOT, 'lib', 'react-commit-cascade-observer.ts') + + // The observer's own graph is allowed to be wide; this only proves the walker + // above sees past the first hop, so an empty shim graph means something. + expect(transitiveSpecifiers(observerFile).size).toBeGreaterThan(3) + }) +}) diff --git a/src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx b/src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx new file mode 100644 index 00000000000..a8c4e7c4c58 --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx @@ -0,0 +1,169 @@ +/** @vitest-environment happy-dom */ +// Why first: react-dom below reads __REACT_DEVTOOLS_GLOBAL_HOOK__ at module +// evaluation. The shim has no imports of its own, so unlike the observer it +// cannot drag the real breadcrumb recorder in ahead of the vi.mock below. +import './react-devtools-commit-hook-shim' +import { act, useLayoutEffect } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { withReactCommitCascadeWriteProbe } from '../store/react-commit-cascade-write-probe' +import { + REACT_CASCADING_LANES, + REACT_COMMIT_CASCADE_BREADCRUMB, + REACT_COMMIT_CASCADE_NOTICE_LIMIT, + resetReactCommitCascadeTelemetryForTests +} from './react-commit-cascade-telemetry' +import { + installReactCommitCascadeObserver, + resetReactCommitCascadeObserverForTests +} from './react-commit-cascade-observer' +import type { ReactDevtoolsCommitHook } from './react-devtools-commit-hook-shim' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const recordBreadcrumb = vi.fn() +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (name: string, data?: unknown) => recordBreadcrumb(name, data) +})) + +/** + * A single-driver cascade measures 438 bytes here, where every frame carries this + * file's long basename; a production bundle name is shorter. Wide enough to sit + * still, narrow enough that a raw stack or an uncapped key list breaks it. + */ +const MAX_CASCADE_CRUMB_BYTES = 768 + +/** Enough to pass the notice limit while staying under React's own 50-commit bail. */ +const CASCADE_TICKS = REACT_COMMIT_CASCADE_NOTICE_LIMIT + 5 + +type CascadeState = { ticks: number; bump: () => void } + +const useCascadeStore = create()( + withReactCommitCascadeWriteProbe((set) => ({ + ticks: 0, + bump: () => { + set({ ticks: useCascadeStore.getState().ticks + 1 }) + } + })) +) + +/** + * Layout effect, not passive: only the synchronous counter throws #185. A + * useEffect loop leaves `pendingLanes: 0` at commit time (measured) because + * passive effects flush after the callback, and React only console.errors it. + */ +function RunawayLayoutEffectPane(): React.JSX.Element { + const ticks = useCascadeStore((state) => state.ticks) + useLayoutEffect(() => { + if (ticks < CASCADE_TICKS) { + useCascadeStore.getState().bump() + } + }) + return
{ticks}
+} + +let host: HTMLDivElement +let root: Root + +const commitHook = (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: ReactDevtoolsCommitHook }) + .__REACT_DEVTOOLS_GLOBAL_HOOK__ + +beforeEach(() => { + recordBreadcrumb.mockReset() + resetReactCommitCascadeTelemetryForTests() + resetReactCommitCascadeObserverForTests() + useCascadeStore.setState({ ticks: 0 }) + // Why cleared rather than deleting the global: react-dom captured this hook + // OBJECT at its own module evaluation, so a replacement object would never be + // called — and reinstalling over the last test's wrapper double-counts commits. + if (commitHook) { + commitHook.onCommitFiberRoot = undefined + } + installReactCommitCascadeObserver() + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + host.remove() +}) + +describe('react commit cascade observer', () => { + it('counts a genuine synchronous cascade and names the store write driving it', () => { + act(() => { + root.render() + }) + + const cascadeCalls = recordBreadcrumb.mock.calls.filter( + ([name]) => name === REACT_COMMIT_CASCADE_BREADCRUMB + ) + expect(cascadeCalls).toHaveLength(1) + + const payload = (cascadeCalls[0]?.[1] ?? {}) as Record + expect(payload.commits).toBe(REACT_COMMIT_CASCADE_NOTICE_LIMIT) + expect(payload.pendingLanes).toBeGreaterThan(0) + expect(payload.storeWrites).toBeGreaterThan(0) + // The middleware boundary is elided, so this is the code that called `set`. + expect(String(payload.driverFrame)).toContain('bump') + expect(payload.changedKeys).toBe('ticks') + expect(payload.rendererSurface).toBe('main') + }) + + // Why counted from outside our own wrapper: a bundler change that installs the + // observer twice counts every commit twice and fires the crumb at half the real + // depth, which no other assertion here can tell from a correct run. + it('counts exactly one commit per hook invocation', () => { + let cascadingInvocations = 0 + const observed = commitHook?.onCommitFiberRoot + if (commitHook) { + // The mount commit holds no cascading lanes, so only these are countable. + commitHook.onCommitFiberRoot = (rendererId, fiberRoot, priorityLevel, didError) => { + const lanes = (fiberRoot as { pendingLanes?: number } | null)?.pendingLanes ?? 0 + if ((lanes & REACT_CASCADING_LANES) !== 0) { + cascadingInvocations += 1 + } + observed?.call(commitHook, rendererId, fiberRoot, priorityLevel, didError) + } + } + let invocationsAtReport: number | undefined + recordBreadcrumb.mockImplementation((name: string) => { + if (name === REACT_COMMIT_CASCADE_BREADCRUMB && invocationsAtReport === undefined) { + invocationsAtReport = cascadingInvocations + } + }) + + act(() => { + root.render() + }) + + expect(invocationsAtReport).toBe(REACT_COMMIT_CASCADE_NOTICE_LIMIT) + }) + + // Why a byte budget: driverStack's key ends in `stack`, which buys 4000 chars, + // so a raw stack or an uncapped key list could be added here unnoticed. + it('keeps the crumb inside a flat byte budget', () => { + act(() => { + root.render() + }) + + const payload = recordBreadcrumb.mock.calls.find( + ([name]) => name === REACT_COMMIT_CASCADE_BREADCRUMB + )?.[1] + expect(JSON.stringify(payload).length).toBeLessThan(MAX_CASCADE_CRUMB_BYTES) + }) + + it('stays silent for a render that settles well below the notice limit', () => { + useCascadeStore.setState({ ticks: CASCADE_TICKS }) + + act(() => { + root.render() + }) + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/react-commit-cascade-observer.test.ts b/src/renderer/src/lib/react-commit-cascade-observer.test.ts new file mode 100644 index 00000000000..2928afdcebc --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-observer.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + REACT_COMMIT_CASCADE_BREADCRUMB, + REACT_COMMIT_CASCADE_NOTICE_LIMIT, + resetReactCommitCascadeTelemetryForTests +} from './react-commit-cascade-telemetry' +import { + REACT_COMMIT_CASCADE_INSTALL_CHECK_MS, + REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB, + installReactCommitCascadeObserver, + resetReactCommitCascadeObserverForTests +} from './react-commit-cascade-observer' +import { + ensureReactDevtoolsCommitHook, + type ReactDevtoolsCommitHook +} from './react-devtools-commit-hook-shim' + +const recordBreadcrumb = vi.fn() +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (name: string, data?: unknown) => recordBreadcrumb(name, data) +})) + +const CASCADING_ROOT = { pendingLanes: 2 } + +function readHook(): ReactDevtoolsCommitHook { + return (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: ReactDevtoolsCommitHook }) + .__REACT_DEVTOOLS_GLOBAL_HOOK__ as ReactDevtoolsCommitHook +} + +beforeEach(() => { + recordBreadcrumb.mockClear() + resetReactCommitCascadeTelemetryForTests() + resetReactCommitCascadeObserverForTests() + // Why the delete: install wraps whatever is there, so a hook left over from + // the previous test would stack a second counter onto the same callback. + delete (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: unknown }).__REACT_DEVTOOLS_GLOBAL_HOOK__ + installReactCommitCascadeObserver() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('react devtools commit hook shim', () => { + // Why asserted: without a pre-existing hook react-dom injects nothing, so a + // packaged build only gets commit callbacks if the shim installs its own. + it('exposes what react-dom requires to inject', () => { + const hook = readHook() + + expect(hook.supportsFiber).toBe(true) + expect(hook.isDisabled).toBe(false) + expect(hook.inject?.({})).toBeGreaterThan(0) + expect(typeof hook.onCommitFiberRoot).toBe('function') + }) + + // Why asserted: react-dom calls this once per DELETED FIBER, guarded only by + // a typeof check. Defining it would put our shim on every unmount path. + it('leaves the per-deleted-fiber callbacks undefined', () => { + const hook = readHook() as Record + + expect(hook.onCommitFiberUnmount).toBeUndefined() + expect(hook.onPostCommitFiberRoot).toBeUndefined() + expect(hook.setStrictMode).toBeUndefined() + }) + + // Why asserted: react-refresh captures this property and calls + // oldOnCommitFiberRoot.apply(this, arguments) with no typeof guard, unlike the + // fallback it gives onScheduleFiberRoot. + it('ships a callable onCommitFiberRoot before anything wraps it', () => { + delete (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: unknown }) + .__REACT_DEVTOOLS_GLOBAL_HOOK__ + const hook = ensureReactDevtoolsCommitHook() + + expect(typeof hook?.onCommitFiberRoot).toBe('function') + expect(() => hook?.onCommitFiberRoot?.(1, null, undefined, false)).not.toThrow() + }) +}) + +describe('installReactCommitCascadeObserver', () => { + it('breadcrumbs once the commit callback reaches the notice limit', () => { + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb.mock.calls[0]?.[0]).toBe(REACT_COMMIT_CASCADE_BREADCRUMB) + }) + + it('ignores a commit whose root reports no cascading lanes', () => { + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT * 2; commit += 1) { + hook.onCommitFiberRoot?.(1, { pendingLanes: 0 }, undefined, false) + } + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + // Why asserted: a future React that stops exposing pendingLanes must make the + // diagnostic go quiet, not fire on a depth it can no longer evaluate. + it('ends the cascade when pendingLanes is not readable', () => { + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + hook.onCommitFiberRoot?.(1, { renamedLanes: 2 }, undefined, false) + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + // Why a string and not undefined: '2' & 42 is 2, so a bitmask-only guard would + // keep the cascade alive on a value React never meant as a lane. + it('ends the cascade when pendingLanes is not a number', () => { + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + hook.onCommitFiberRoot?.(1, { pendingLanes: '2' }, undefined, false) + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + it('chains onto an existing callback instead of replacing it', () => { + const previous = vi.fn() + const hook = readHook() + hook.onCommitFiberRoot = previous + resetReactCommitCascadeObserverForTests() + installReactCommitCascadeObserver() + + hook.onCommitFiberRoot?.(7, CASCADING_ROOT, undefined, true) + + expect(previous).toHaveBeenCalledWith(7, CASCADING_ROOT, undefined, true) + }) + + // Why asserted: our throw would otherwise land in react-dom's own catch and + // silently unhook DevTools and Fast Refresh from the rest of the chain. + it('still calls the chained hook when our own work throws', () => { + const previous = vi.fn() + const hook = readHook() + hook.onCommitFiberRoot = previous + resetReactCommitCascadeObserverForTests() + installReactCommitCascadeObserver() + + const hostile = { + get pendingLanes(): number { + throw new Error('hostile root') + } + } + expect(() => hook.onCommitFiberRoot?.(1, hostile, undefined, false)).not.toThrow() + expect(previous).toHaveBeenCalledTimes(1) + }) + + it('is idempotent, so a second install cannot double-count commits', () => { + installReactCommitCascadeObserver() + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) +}) + +// Why: a reshuffled import or a bundler hoist disables the diagnostic with no +// test failure. This is what makes that visible in the field instead. +describe('install self-check', () => { + it('breadcrumbs when no commit ever reached the hook', () => { + vi.useFakeTimers() + resetReactCommitCascadeObserverForTests() + installReactCommitCascadeObserver() + + vi.advanceTimersByTime(REACT_COMMIT_CASCADE_INSTALL_CHECK_MS) + + expect(recordBreadcrumb).toHaveBeenCalledWith( + REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB, + undefined + ) + }) + + it('stays silent once any commit has been seen', () => { + vi.useFakeTimers() + resetReactCommitCascadeObserverForTests() + installReactCommitCascadeObserver() + readHook().onCommitFiberRoot?.(1, { pendingLanes: 0 }, undefined, false) + + vi.advanceTimersByTime(REACT_COMMIT_CASCADE_INSTALL_CHECK_MS) + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + // Why asserted: a hook that refuses the assignment is exactly the case the + // self-check exists for, so it must arm on the failure path too. + it('breadcrumbs when the hook refuses the callback assignment', () => { + vi.useFakeTimers() + resetReactCommitCascadeObserverForTests() + const frozenHook = Object.freeze({ isDisabled: false, supportsFiber: true }) + ;(globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: unknown }).__REACT_DEVTOOLS_GLOBAL_HOOK__ = + frozenHook + + expect(() => installReactCommitCascadeObserver()).not.toThrow() + vi.advanceTimersByTime(REACT_COMMIT_CASCADE_INSTALL_CHECK_MS) + + expect((frozenHook as { onCommitFiberRoot?: unknown }).onCommitFiberRoot).toBeUndefined() + expect(recordBreadcrumb).toHaveBeenCalledWith( + REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB, + undefined + ) + }) + + it('breadcrumbs when no hook can be reached at all', () => { + vi.useFakeTimers() + resetReactCommitCascadeObserverForTests() + Object.defineProperty(globalThis, '__REACT_DEVTOOLS_GLOBAL_HOOK__', { + configurable: true, + get() { + throw new Error('no devtools global') + } + }) + + expect(() => installReactCommitCascadeObserver()).not.toThrow() + vi.advanceTimersByTime(REACT_COMMIT_CASCADE_INSTALL_CHECK_MS) + + expect(recordBreadcrumb).toHaveBeenCalledWith( + REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB, + undefined + ) + }) +}) + +/** + * Why an allocation assertion and not a timing one: this callback runs on every + * commit the app makes, so the cost that matters is garbage. Measured on this + * loop, an escaping object literal, a template string and a per-call closure + * each cost more than 3.8 MB where the current code costs tens of KB. + */ +describe('commit hook cost', () => { + const HOT_COMMITS = 1_000_000 + /** ~30x the measured zero-allocation baseline, ~4x under the cheapest regression. */ + const MAX_HEAP_GROWTH_BYTES = 1_000_000 + + function measureHeapGrowth(commit: () => void): number { + const collectGarbage = (globalThis as { gc?: () => void }).gc + // --expose-gc is pinned in config/vitest.config.ts execArgv. + expect(typeof collectGarbage).toBe('function') + for (let warmup = 0; warmup < 50_000; warmup += 1) { + commit() + } + collectGarbage?.() + const before = process.memoryUsage().heapUsed + for (let iteration = 0; iteration < HOT_COMMITS; iteration += 1) { + commit() + } + return process.memoryUsage().heapUsed - before + } + + it('allocates nothing on an ordinary commit', () => { + const hook = readHook() + const quietRoot = { pendingLanes: 0 } + + const growth = measureHeapGrowth(() => hook.onCommitFiberRoot?.(1, quietRoot, undefined, false)) + + expect(growth).toBeLessThan(MAX_HEAP_GROWTH_BYTES) + }) + + // Why separately: the cascading branch runs the counter, the arm check and the + // report guard, none of which may start allocating once a cascade is deep. + it('allocates nothing while a cascade is being counted', () => { + const hook = readHook() + + const growth = measureHeapGrowth(() => + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + ) + + expect(growth).toBeLessThan(MAX_HEAP_GROWTH_BYTES) + }) +}) + +// Why asserted: main.tsx imports this module for its side effect alone, so +// deleting the bare install call would disable the diagnostic in production. +describe('module self-install', () => { + it('counts commits after nothing but an import', async () => { + vi.useFakeTimers() + delete (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: unknown }) + .__REACT_DEVTOOLS_GLOBAL_HOOK__ + vi.resetModules() + + await import('./react-commit-cascade-observer') + + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + expect(recordBreadcrumb).toHaveBeenCalledWith( + REACT_COMMIT_CASCADE_BREADCRUMB, + expect.objectContaining({ commits: REACT_COMMIT_CASCADE_NOTICE_LIMIT }) + ) + }) +}) diff --git a/src/renderer/src/lib/react-commit-cascade-observer.ts b/src/renderer/src/lib/react-commit-cascade-observer.ts new file mode 100644 index 00000000000..bfac75898bd --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-observer.ts @@ -0,0 +1,101 @@ +/** + * Installs the per-commit hook that feeds the cascade telemetry. + * + * Why the devtools callback and not or a root effect: Profiler is + * stripped from production react-dom, and a dependency-less useLayoutEffect + * fires per render of its OWN component, not per commit — measured, a root + * effect saw 1 of 11 commits a leaf drove. react-dom calls onCommitFiberRoot + * once per commit per root in both bundles, inside its own try/catch, and hands + * us the FiberRoot whose `pendingLanes` drive the reset rule. + * + * Ordering: this module only WRAPS `onCommitFiberRoot`, which react-dom re-reads + * at every commit, so it may be imported anywhere. The deadline belongs to + * react-devtools-commit-hook-shim, which must merely exist before react-dom's + * module evaluation and is therefore the entries' first import. + */ +import { observeReactCommit } from '@/lib/react-commit-cascade-telemetry' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { + ensureReactDevtoolsCommitHook, + type ReactDevtoolsCommitHook +} from '@/lib/react-devtools-commit-hook-shim' + +export const REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB = 'react_commit_cascade_uninstalled' + +/** Long enough that any live renderer has committed; short enough to reach a crash report. */ +export const REACT_COMMIT_CASCADE_INSTALL_CHECK_MS = 10_000 + +type FiberRootLike = { pendingLanes?: unknown } + +let installed = false +let commitsSeen = 0 +let installCheckTimer: ReturnType | undefined + +/** + * A reshuffled import or a bundler hoist would disable the diagnostic with no + * test failure. Make that visible in the field instead of silent. + */ +function scheduleInstallSelfCheck(): void { + if (installCheckTimer !== undefined || typeof setTimeout !== 'function') { + return + } + installCheckTimer = setTimeout(() => { + if (commitsSeen === 0) { + recordRendererCrashBreadcrumb(REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB) + } + }, REACT_COMMIT_CASCADE_INSTALL_CHECK_MS) + // Why unref where available: the check must never hold a renderer teardown open. + ;(installCheckTimer as { unref?: () => void })?.unref?.() +} + +export function installReactCommitCascadeObserver(): void { + if (installed) { + return + } + try { + const hook = ensureReactDevtoolsCommitHook() + if (hook) { + installObserverOnHook(hook) + // Why only after the assignment: a hook that refuses it stays uninstalled, + // so a later call can retry rather than being gated out. + installed = true + } + } catch { + // A renderer without a patchable global still has to boot. + } + // Why outside the try: a failed install is exactly when the crumb matters. + scheduleInstallSelfCheck() +} + +function installObserverOnHook(hook: ReactDevtoolsCommitHook): void { + const previous = hook.onCommitFiberRoot + // Fixed arity, not rest args: this runs on every commit and must not allocate. + hook.onCommitFiberRoot = (rendererId, root, priorityLevel, didError) => { + // Why our own try/catch when react-dom already has one: theirs would + // swallow the rest of this callback, silently unhooking DevTools and + // Fast Refresh from the chain below. + try { + commitsSeen += 1 + const pendingLanes = (root as FiberRootLike | null)?.pendingLanes + // Why 0 and not a skip: a React shape change that hides pendingLanes must + // end the cascade, not freeze a stale depth that later commits push over + // the limit. 0 is the lane value that means "nothing pending", so it + // takes the same reset path and this fails safe to no crumb. + observeReactCommit(root, typeof pendingLanes === 'number' ? pendingLanes : 0) + } catch { + // Best-effort crash evidence only. + } + previous?.call(hook, rendererId, root, priorityLevel, didError) + } +} + +export function resetReactCommitCascadeObserverForTests(): void { + installed = false + commitsSeen = 0 + if (installCheckTimer !== undefined) { + clearTimeout(installCheckTimer) + installCheckTimer = undefined + } +} + +installReactCommitCascadeObserver() diff --git a/src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts b/src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts new file mode 100644 index 00000000000..17489399883 --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { create } from 'zustand' +import { withReactCommitCascadeWriteProbe } from '../store/react-commit-cascade-write-probe' +import { + MAX_REPORTED_CHANGED_KEYS, + MAX_SAMPLED_WRITES, + armReactCommitCascadeWriteSampling, + noteReactCommitCascadeStoreWrite, + readReactCommitCascadeWriteSummary, + resetReactCommitCascadeWriteSamples +} from './react-commit-cascade-store-write-samples' + +// Not an intersection with ErrorConstructor: both fields have to stay optional +// so the absent-captureStackTrace platform can be simulated. +type ErrorWithCapture = { + captureStackTrace?: (target: object, constructorOpt?: unknown) => void + stackTraceLimit?: number +} +const errorWithCapture = Error as unknown as ErrorWithCapture +const originalCapture = errorWithCapture.captureStackTrace +const originalLimit = errorWithCapture.stackTraceLimit + +/** Stands in for the zustand slice action that calls `set`. */ +function storeAction(): void { + noteReactCommitCascadeStoreWrite(storeAction, { ticks: 1 }) +} +function runawayEffect(): void { + storeAction() +} +function paneRender(): void { + runawayEffect() +} + +beforeEach(() => { + resetReactCommitCascadeWriteSamples() +}) + +afterEach(() => { + resetReactCommitCascadeWriteSamples() + errorWithCapture.captureStackTrace = originalCapture + errorWithCapture.stackTraceLimit = originalLimit +}) + +describe('driver stack frames', () => { + // Why more than one frame: frame 0 is the slice action every cascade shares. + // The caller above it is the loop we are actually hunting. + it('names callers above the frame the write came from', () => { + armReactCommitCascadeWriteSampling() + paneRender() + + const summary = readReactCommitCascadeWriteSummary() + const frames = summary.driverStack?.split('\n') ?? [] + expect(summary.driverFrame).toContain('runawayEffect') + expect(frames[0]).toBe(summary.driverFrame) + expect(frames.length).toBeGreaterThan(1) + expect(summary.driverStack).toContain('paneRender') + expect(summary.driverFrame).not.toContain('paneRender') + }) + + // Why: the redaction that strips paths is keyed on the detail NAME, so a path + // inside this value would ship a developer's home directory. + it('keeps every reported frame path-free', () => { + armReactCommitCascadeWriteSampling() + paneRender() + + expect(readReactCommitCascadeWriteSummary().driverStack).not.toContain('/') + }) +}) + +describe('capture failures', () => { + // Why asserted: the limit is process-global, so leaking a raised value would + // make every unrelated throw in the app capture eight frames forever. + it('restores Error.stackTraceLimit when the capture throws', () => { + errorWithCapture.stackTraceLimit = 3 + errorWithCapture.captureStackTrace = () => { + throw new Error('capture refused') + } + armReactCommitCascadeWriteSampling() + + expect(() => storeAction()).not.toThrow() + expect(errorWithCapture.stackTraceLimit).toBe(3) + expect(readReactCommitCascadeWriteSummary().storeWrites).toBe(1) + }) + + // Why asserted: no sample is ever pushed without captureStackTrace, so a cap + // bound to samples.length would leave key collection running for the whole + // cascade on any engine that lacks the API. + it('stops collecting after the sample cap when captureStackTrace is unavailable', () => { + delete errorWithCapture.captureStackTrace + armReactCommitCascadeWriteSampling() + for (let write = 0; write < 100; write += 1) { + noteReactCommitCascadeStoreWrite(storeAction, { [`key${write}`]: write }) + } + // Restored before asserting: vitest builds its own failure stacks with it. + errorWithCapture.captureStackTrace = originalCapture + + const summary = readReactCommitCascadeWriteSummary() + expect(summary.storeWrites).toBe(100) + expect(summary.driverFrame).toBeUndefined() + expect(summary.storeWriteSites).toBe(0) + expect(summary.changedKeys?.split(',')).toHaveLength(MAX_SAMPLED_WRITES) + expect(summary.changedKeys).not.toContain('key99') + expect(summary.changedKeys?.length).toBeLessThan(240) + }) + + // Why still asserted: one write can change many keys, so the report cap is + // what keeps the detail inside its 240-char budget. + it('caps reported changed keys from a single wide write', () => { + const wide = Object.fromEntries( + Array.from({ length: 40 }, (_, index) => [`key${index}`, index]) + ) + armReactCommitCascadeWriteSampling() + noteReactCommitCascadeStoreWrite(storeAction, wide) + + const summary = readReactCommitCascadeWriteSummary() + expect(summary.changedKeys?.split(',')).toHaveLength(MAX_REPORTED_CHANGED_KEYS) + expect(summary.changedKeys?.length).toBeLessThan(240) + }) +}) + +// Why measured through the real middleware: the arming condition is what keeps +// an ordinary write down to one boolean read, and nothing else asserts it end to end. +describe('disarmed cost', () => { + const WRITE_FLOOD = 10_000 + + it('captures no stack across a write flood with no cascade', () => { + const useFloodStore = create<{ ticks: number }>()( + withReactCommitCascadeWriteProbe(() => ({ ticks: 0 })) + ) + let captures = 0 + errorWithCapture.captureStackTrace = (target, boundary) => { + captures += 1 + originalCapture?.(target, boundary) + } + + for (let write = 0; write < WRITE_FLOOD; write += 1) { + useFloodStore.setState({ ticks: write }) + } + + expect(captures).toBe(0) + expect(useFloodStore.getState().ticks).toBe(WRITE_FLOOD - 1) + expect(readReactCommitCascadeWriteSummary().storeWrites).toBe(0) + }) +}) diff --git a/src/renderer/src/lib/react-commit-cascade-store-write-samples.ts b/src/renderer/src/lib/react-commit-cascade-store-write-samples.ts new file mode 100644 index 00000000000..ef8565255a8 --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-store-write-samples.ts @@ -0,0 +1,157 @@ +/** + * Samples the store writes made during a suspected React commit cascade. + * + * Why sampling writes and not the throw site: React #185 lands on whichever + * fiber dispatched after a root-global counter tripped, so the reported + * component is a bystander (see shared/react-update-depth-attribution.ts). The + * writes that kept scheduling are the only frames that identify the loop. + * + * Cost discipline: the armed flag is the only thing the store-write path reads + * when no cascade is suspected, capture is capped per cascade, and frames are + * formatted at report time so a sampled write pays for nothing it may not use. + */ + +/** + * Read on every store write, so it is a plain field load on a shared object + * rather than a cross-module call. + */ +export const reactCommitCascadeWriteProbe = { armed: false } + +/** Frame 0 is the store action; the rest reach up into whatever loops over it. */ +const CAPTURE_STACK_FRAME_LIMIT = 8 +/** + * Frame 0 alone names a slice action, which every cascade shares. Two frames of + * caller is what distinguishes the effect driving the loop from its neighbours. + */ +const REPORTED_FRAMES_PER_SITE = 3 +/** Whole frames only, inside the 4000-char budget a `stack`-suffixed detail gets. */ +const MAX_DRIVER_STACK_CHARS = 3_800 +/** A loop repeats; six samples name every distinct participant worth naming. */ +export const MAX_SAMPLED_WRITES = 6 +/** Keeps `changedKeys` inside the 240-char detail cap without truncating mid-key. */ +export const MAX_REPORTED_CHANGED_KEYS = 12 + +type SampledWrite = { stack?: string } + +let storeWrites = 0 +let samples: SampledWrite[] = [] +let changedKeys: Set | null = null + +export type ReactCommitCascadeWriteSummary = { + storeWrites: number + storeWriteSites: number + driverFrame: string | undefined + driverStack: string | undefined + changedKeys: string | undefined +} + +export function armReactCommitCascadeWriteSampling(): void { + reactCommitCascadeWriteProbe.armed = true +} + +export function resetReactCommitCascadeWriteSamples(): void { + reactCommitCascadeWriteProbe.armed = false + storeWrites = 0 + samples = [] + changedKeys = null +} + +/** + * Call only while armed. `boundary` is the wrapping `set` function, so V8 elides + * our own frames and the first captured frame is the real caller. Typed as + * `object` because zustand's `set` is an overload set, not a plain signature. + */ +export function noteReactCommitCascadeStoreWrite(boundary: object, partial: unknown): void { + storeWrites += 1 + // Why the write count and not samples.length: samples only grows where + // Error.captureStackTrace exists, so that cap would never engage without it and + // key collection would run for the whole cascade. + if (storeWrites > MAX_SAMPLED_WRITES) { + return + } + // Why object-only: a functional updater's keys are unknowable without running it. + if (partial && typeof partial === 'object') { + changedKeys ??= new Set() + for (const key of Object.keys(partial)) { + changedKeys.add(key) + } + } + const capture = Error as ErrorConstructor & { + captureStackTrace?: (target: object, constructorOpt?: unknown) => void + stackTraceLimit?: number + } + if (typeof capture.captureStackTrace !== 'function') { + return + } + const previousLimit = capture.stackTraceLimit + const sample: SampledWrite = {} + try { + capture.stackTraceLimit = CAPTURE_STACK_FRAME_LIMIT + capture.captureStackTrace(sample, boundary) + samples.push(sample) + } catch { + // Best-effort crash evidence only. + } finally { + capture.stackTraceLimit = previousLimit + } +} + +/** `at fn (/Users/me/app/src/x.ts:1:2)` becomes `x.ts:1:2 fn`. */ +function reduceFrame(frame: string): string | undefined { + const match = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?\s*$/.exec(frame) + if (!match) { + return undefined + } + const [, fn, location, line, column] = match + // Why basename only: the redaction that strips paths is keyed on the detail + // NAME, so a full path inside this value would ship a developer's home dir. + const basename = location?.split(/[/\\]/).pop() ?? '' + return fn ? `${basename}:${line}:${column} ${fn}` : `${basename}:${line}:${column}` +} + +/** One reduced frame group per distinct call site, deepest frame first. */ +function sampledSiteFrames(): string[][] { + const sites: string[][] = [] + const seen = new Set() + for (const sample of samples) { + const frames = (sample.stack?.split('\n') ?? []) + .map(reduceFrame) + .filter((frame): frame is string => frame !== undefined) + .slice(0, REPORTED_FRAMES_PER_SITE) + const site = frames[0] + if (site === undefined || seen.has(site)) { + continue + } + seen.add(site) + sites.push(frames) + } + return sites +} + +/** Drops whole frames, so a truncated stack never ends mid-frame. */ +function joinWithinBudget(frames: string[]): string | undefined { + const kept: string[] = [] + let remaining = MAX_DRIVER_STACK_CHARS + for (const frame of frames) { + if (frame.length + 1 > remaining) { + break + } + kept.push(frame) + remaining -= frame.length + 1 + } + return kept.length > 0 ? kept.join('\n') : undefined +} + +/** Formats only here, so an unreported cascade pays nothing for its samples. */ +export function readReactCommitCascadeWriteSummary(): ReactCommitCascadeWriteSummary { + const sites = sampledSiteFrames() + const keys = changedKeys ? Array.from(changedKeys).slice(0, MAX_REPORTED_CHANGED_KEYS) : [] + return { + storeWrites, + storeWriteSites: sites.length, + // Why frame 0 stays alone: the main-side coalesce key clusters on it. + driverFrame: sites[0]?.[0], + driverStack: joinWithinBudget(sites.flat()), + changedKeys: keys.length > 0 ? keys.join(',') : undefined + } +} diff --git a/src/renderer/src/lib/react-commit-cascade-telemetry.test.ts b/src/renderer/src/lib/react-commit-cascade-telemetry.test.ts new file mode 100644 index 00000000000..89e0456c2ce --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-telemetry.test.ts @@ -0,0 +1,339 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sanitizeCrashReportDetails } from '../../../shared/crash-report-redaction' +import { REACT_NESTED_UPDATE_LIMIT } from '../../../shared/react-update-depth-attribution' +import { + REACT_CASCADING_LANES, + setReactCommitCascadeRendererSurface, + REACT_COMMIT_CASCADE_ARM_COMMITS, + REACT_COMMIT_CASCADE_BREADCRUMB, + REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS, + REACT_COMMIT_CASCADE_NOTICE_LIMIT, + createReactCommitCascadeState, + recordReactCommit, + resetReactCommitCascadeTelemetryForTests, + type ReactCommitCascadeState +} from './react-commit-cascade-telemetry' +import { + noteReactCommitCascadeStoreWrite, + reactCommitCascadeWriteProbe, + readReactCommitCascadeWriteSummary +} from './react-commit-cascade-store-write-samples' + +const recordBreadcrumb = vi.fn() +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (name: string, data?: unknown) => recordBreadcrumb(name, data) +})) + +const ROOT = { pendingLanes: 2 } +const OTHER_ROOT = { pendingLanes: 2 } +/** SyncLane; what react-dom leaves pending on a cascading commit. */ +const SYNC_LANE = 2 +/** Any lane outside React's nested-update mask ends the cascade. */ +const IDLE_LANE = 1_073_741_824 + +function cascadePayload(callIndex = 0): Record { + const call = recordBreadcrumb.mock.calls[callIndex] + return (call?.[1] ?? {}) as Record +} + +function driveCommits(args: { + state: ReactCommitCascadeState + count: number + root?: unknown + lanes?: number + nowMs?: number + onCommit?: () => void +}): void { + const readNowMs = (): number => args.nowMs ?? 1_000 + for (let index = 0; index < args.count; index += 1) { + recordReactCommit({ + state: args.state, + root: args.root ?? ROOT, + pendingLanes: args.lanes ?? SYNC_LANE, + readNowMs + }) + args.onCommit?.() + } +} + +beforeEach(() => { + recordBreadcrumb.mockClear() + resetReactCommitCascadeTelemetryForTests() +}) + +// Why asserted: both thresholds only mean anything relative to React's own bail. +// If the notice limit ever meets it, the crumb stops beating the throw. +describe('cascade thresholds', () => { + it('derives arm and notice limits from React commit budget', () => { + expect(REACT_COMMIT_CASCADE_NOTICE_LIMIT).toBeLessThan(REACT_NESTED_UPDATE_LIMIT) + expect(REACT_COMMIT_CASCADE_ARM_COMMITS).toBeLessThan(REACT_COMMIT_CASCADE_NOTICE_LIMIT) + expect(REACT_CASCADING_LANES).toBe(42) + }) +}) + +describe('recordReactCommit', () => { + it('stays silent below the notice limit', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1 }) + + expect(recordBreadcrumb).not.toHaveBeenCalled() + expect(state.commits).toBe(REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1) + }) + + it('fires exactly once at the notice limit and not again in the same cascade', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT * 3 }) + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb.mock.calls[0]?.[0]).toBe(REACT_COMMIT_CASCADE_BREADCRUMB) + expect(cascadePayload().commits).toBe(REACT_COMMIT_CASCADE_NOTICE_LIMIT) + }) + + it('emits flat primitives only', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT }) + + const payload = cascadePayload() + expect( + Object.values(payload).every((value) => value === null || typeof value !== 'object') + ).toBe(true) + expect(payload).toMatchObject({ + commits: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + commitBudget: REACT_NESTED_UPDATE_LIMIT, + pendingLanes: SYNC_LANE, + storeWrites: 0, + storeWriteSites: 0, + rendererSurface: 'main' + }) + }) + + // Why this is the whole reset rule: React zeroes its counter the moment a + // commit leaves no sync lanes pending, so a lull must not accumulate. + it('ends the cascade when the root stops holding cascading lanes', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1 }) + + driveCommits({ state, count: 1, lanes: 0 }) + + expect(state.commits).toBe(0) + expect(state.cascadeRoot).toBeNull() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1 }) + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + it('ignores lanes outside React nested-update mask', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT * 2, lanes: IDLE_LANE }) + + expect(state.commits).toBe(0) + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + // Why per root: React's counter is root-global but not cross-root, so two + // roots alternating commits are not one cascade. + it('restarts the count when a different root commits', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1 }) + + driveCommits({ state, count: 1, root: OTHER_ROOT }) + + expect(state.commits).toBe(1) + expect(state.cascadeRoot).toBe(OTHER_ROOT) + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + it('honours explicit limit overrides', () => { + const state = createReactCommitCascadeState() + for (let index = 0; index < 4; index += 1) { + recordReactCommit({ + state, + root: ROOT, + pendingLanes: SYNC_LANE, + readNowMs: () => 1_000, + noticeLimit: 4, + armCommits: 2 + }) + } + + expect(cascadePayload().commits).toBe(4) + }) + + // Why asserted: a clock read per commit is the one cost this design refuses. + it('reads the clock only at arm and at report', () => { + const state = createReactCommitCascadeState() + const readNowMs = vi.fn(() => 1_000) + for (let index = 0; index < REACT_COMMIT_CASCADE_NOTICE_LIMIT; index += 1) { + recordReactCommit({ state, root: ROOT, pendingLanes: SYNC_LANE, readNowMs }) + } + + expect(readNowMs).toHaveBeenCalledTimes(2) + }) +}) + +// Why asserted: the popout is a separate BrowserWindow with its own module +// state, so the payload is the only thing that says which window cascaded. +describe('renderer surface', () => { + it('reports the surface the cascade happened on', () => { + setReactCommitCascadeRendererSurface('dashboard-popout') + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT }) + + expect(cascadePayload().rendererSurface).toBe('dashboard-popout') + }) +}) + +describe('driver attribution', () => { + it('arms write sampling at the arm floor, not before', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_ARM_COMMITS - 1 }) + expect(reactCommitCascadeWriteProbe.armed).toBe(false) + + driveCommits({ state, count: 1 }) + expect(reactCommitCascadeWriteProbe.armed).toBe(true) + }) + + it('names the driving write with a path-free frame', () => { + const state = createReactCommitCascadeState() + function drivingWrite(): void { + noteReactCommitCascadeStoreWrite(drivingWrite, { tabs: [] }) + } + driveCommits({ + state, + count: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + onCommit: () => { + if (reactCommitCascadeWriteProbe.armed) { + drivingWrite() + } + } + }) + + const payload = cascadePayload() + expect(payload.storeWrites).toBeGreaterThan(0) + // Why the caller and not drivingWrite itself: captureStackTrace elides the + // boundary frame, so in production the first frame is whoever called `set`. + expect(String(payload.driverFrame)).toContain('onCommit') + expect(payload.changedKeys).toBe('tabs') + // Why: the path redaction is keyed on the detail NAME, so a path inside the + // value would ship a developer's home directory. + expect(String(payload.driverStack)).not.toContain('/') + // Why sanitize instead of matching the key name: the 4000-char budget is + // decided by a camel-split rule in crash-report-redaction, so a hand-copied + // regex here can pass while the real rule drops the frames to 240. + // Why resolve the key from the payload: hard-coding it here is the same + // drift this assertion exists to catch. + const stackKey = Object.keys(payload).find( + (key) => key !== 'driverFrame' && String(payload[key]).includes(String(payload.driverFrame)) + ) + expect(stackKey).toBeDefined() + const longFrames = 'a'.repeat(600) + const sanitized = sanitizeCrashReportDetails({ ...payload, [stackKey as string]: longFrames }) + expect(String(sanitized[stackKey as string])).toHaveLength(longFrames.length) + }) + + it('clears samples when a cascade ends', () => { + const state = createReactCommitCascadeState() + function drivingWrite(): void { + noteReactCommitCascadeStoreWrite(drivingWrite, { tabs: [] }) + } + driveCommits({ + state, + count: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + onCommit: () => { + if (reactCommitCascadeWriteProbe.armed) { + drivingWrite() + } + } + }) + recordBreadcrumb.mockClear() + + driveCommits({ state, count: 1, lanes: 0 }) + driveCommits({ + state, + count: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + nowMs: 1_000 + REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS + 1 + }) + + expect(cascadePayload().storeWrites).toBe(0) + expect(cascadePayload().driverFrame).toBeUndefined() + }) +}) + +describe('report throttle', () => { + it('suppresses cascades inside the interval and carries the count forward', () => { + const state = createReactCommitCascadeState() + for (let cascade = 0; cascade < 3; cascade += 1) { + driveCommits({ state, count: 1, lanes: 0 }) + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT }) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(state.suppressed).toBe(2) + + driveCommits({ state, count: 1, lanes: 0 }) + driveCommits({ + state, + count: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + nowMs: 1_000 + REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS + 1 + }) + + expect(recordBreadcrumb).toHaveBeenCalledTimes(2) + expect(cascadePayload(1).suppressed).toBe(2) + expect(state.suppressed).toBe(0) + }) +}) + +/** + * Why dimensional and not just functional: a cascade that ends releases its + * samples, but a renderer that oscillates for minutes runs that arm/end cycle + * thousands of times, and anything the reset misses accumulates there. + */ +describe('repeated arm and end cycles', () => { + const CYCLES = 1_000 + /** The steady state is a few KB of live objects; a per-cycle leak is megabytes. */ + const MAX_HEAP_GROWTH_BYTES = 1_000_000 + + function drivingWrite(): void { + noteReactCommitCascadeStoreWrite(drivingWrite, { tabs: [], panes: [] }) + } + + function runCascadeCycle(state: ReactCommitCascadeState): void { + driveCommits({ + state, + count: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + onCommit: () => { + if (reactCommitCascadeWriteProbe.armed) { + drivingWrite() + } + } + }) + driveCommits({ state, count: 1, lanes: 0 }) + } + + it('leaves nothing behind across a thousand cascades', () => { + const collectGarbage = (globalThis as { gc?: () => void }).gc + // --expose-gc is pinned in config/vitest.config.ts execArgv. + expect(typeof collectGarbage).toBe('function') + const state = createReactCommitCascadeState() + for (let warmup = 0; warmup < 20; warmup += 1) { + runCascadeCycle(state) + } + collectGarbage?.() + const before = process.memoryUsage().heapUsed + + for (let cycle = 0; cycle < CYCLES; cycle += 1) { + runCascadeCycle(state) + } + collectGarbage?.() + + expect(process.memoryUsage().heapUsed - before).toBeLessThan(MAX_HEAP_GROWTH_BYTES) + expect(reactCommitCascadeWriteProbe.armed).toBe(false) + expect(readReactCommitCascadeWriteSummary()).toMatchObject({ + storeWrites: 0, + storeWriteSites: 0, + driverFrame: undefined, + driverStack: undefined, + changedKeys: undefined + }) + expect(state.cascadeRoot).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/react-commit-cascade-telemetry.ts b/src/renderer/src/lib/react-commit-cascade-telemetry.ts new file mode 100644 index 00000000000..73051d21599 --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-telemetry.ts @@ -0,0 +1,205 @@ +/** + * Counts consecutive same-root commits that keep scheduling synchronous work, + * and breadcrumbs the cascade before React #185 throws. + * + * This mirrors React's own accounting rather than approximating it with a time + * window: react-dom resets its nested-update counter the moment a commit leaves + * no sync lanes pending, and increments it otherwise. Reading `root.pendingLanes` + * at commit time reproduces that reset exactly. + * + * Known over-count: React also requires the committed lanes to intersect its own + * mask, which we cannot read from the devtools callback. Dropping that term can + * only make us count a commit React would have skipped, so we fire early, never + * late — the safe direction for a diagnostic that must beat the throw. + * + * Out of scope by construction: a passive-effect (useEffect) loop reports + * pendingLanes 0 here, because passive effects flush after the commit callback. + * That is correct — React tracks those in nestedPassiveUpdateCount, which only + * console.errors in development and never throws #185. + */ +import { compactBreadcrumbData } from '@/lib/crash-breadcrumb-data' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { + armReactCommitCascadeWriteSampling, + readReactCommitCascadeWriteSummary, + resetReactCommitCascadeWriteSamples +} from '@/lib/react-commit-cascade-store-write-samples' +import { readStoreListenerCount } from '@/store/store-listener-census' +import type { RendererSurface } from '@/lib/renderer-memory-sampling' +import { REACT_NESTED_UPDATE_LIMIT } from '../../../shared/react-update-depth-attribution' + +export const REACT_COMMIT_CASCADE_BREADCRUMB = 'react_commit_cascade' + +/** + * SyncLane | InputContinuousLane | DefaultLane. A root still holding any of + * these after a commit is what React counts as a nested update. + */ +export const REACT_CASCADING_LANES = 42 + +/** Headroom for the crumb to be built and sent before React throws above 50. */ +const REACT_COMMIT_CASCADE_NOTICE_HEADROOM = 10 +export const REACT_COMMIT_CASCADE_NOTICE_LIMIT = + REACT_NESTED_UPDATE_LIMIT - REACT_COMMIT_CASCADE_NOTICE_HEADROOM +/** + * Nothing in the app legitimately reaches 20 consecutive same-root cascading + * commits — the two deliberate oscillation loops are capped at 8 and 3 — and it + * leaves 20 commits for the sampled writes to land inside the loop. + */ +export const REACT_COMMIT_CASCADE_ARM_COMMITS = 20 +/** Matches RENDERER_BREADCRUMB_COALESCE_MS; main drops anything faster anyway. */ +export const REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS = 30_000 + +export type ReactCommitCascadeState = { + /** + * Held strongly, and never dereferenced. The next commit that leaves no + * cascading lanes clears the slot — milliseconds away in a live renderer — + * so the only tree this pins is a React root unmounted mid-cascade, such as + * the per-decoration roots in useDiffCommentDecorator. + */ + cascadeRoot: unknown + commits: number + reported: boolean + armedAtMs: number | null + lastReportedAtMs: number | null + /** Cascades that reached the notice limit while the report interval was live. */ + suppressed: number +} + +export function createReactCommitCascadeState(): ReactCommitCascadeState { + return { + cascadeRoot: null, + commits: 0, + reported: false, + armedAtMs: null, + lastReportedAtMs: null, + suppressed: 0 + } +} + +const sharedState = createReactCommitCascadeState() +let rendererSurface: RendererSurface = 'main' + +export function setReactCommitCascadeRendererSurface(surface: RendererSurface): void { + rendererSurface = surface +} + +export function resetReactCommitCascadeTelemetryForTests(): void { + Object.assign(sharedState, createReactCommitCascadeState()) + rendererSurface = 'main' + resetReactCommitCascadeWriteSamples() +} + +function endCascade(state: ReactCommitCascadeState): void { + state.cascadeRoot = null + state.commits = 0 + state.reported = false + state.armedAtMs = null + resetReactCommitCascadeWriteSamples() +} + +function reportCascade(state: ReactCommitCascadeState, pendingLanes: number, nowMs: number): void { + const suppressed = state.suppressed + state.suppressed = 0 + state.lastReportedAtMs = nowMs + const writes = readReactCommitCascadeWriteSummary() + recordRendererCrashBreadcrumb( + REACT_COMMIT_CASCADE_BREADCRUMB, + compactBreadcrumbData({ + commits: state.commits, + commitBudget: REACT_NESTED_UPDATE_LIMIT, + elapsedMs: state.armedAtMs === null ? undefined : nowMs - state.armedAtMs, + pendingLanes, + // Why 0 is worth shipping: it says the loop is useState-driven, not store-driven. + storeWrites: writes.storeWrites, + storeWriteSites: writes.storeWriteSites, + driverFrame: writes.driverFrame, + driverStack: writes.driverStack, + changedKeys: writes.changedKeys, + storeListeners: readStoreListenerCount() ?? undefined, + rendererSurface, + suppressed: suppressed > 0 ? suppressed : undefined + }) + ) +} + +function recordCommit( + state: ReactCommitCascadeState, + root: unknown, + pendingLanes: number, + readNowMs: () => number, + noticeLimit: number, + armCommits: number, + minReportIntervalMs: number +): void { + if ((pendingLanes & REACT_CASCADING_LANES) === 0) { + if (state.cascadeRoot !== null) { + endCascade(state) + } + return + } + if (root !== state.cascadeRoot) { + endCascade(state) + state.cascadeRoot = root + } + + state.commits += 1 + if (state.commits < armCommits) { + return + } + if (state.commits === armCommits) { + state.armedAtMs = readNowMs() + armReactCommitCascadeWriteSampling() + return + } + if (state.reported || state.commits < noticeLimit) { + return + } + + state.reported = true + const nowMs = readNowMs() + // Why the renderer throttles too: nothing rate-limits this pipe, and every + // call is a structured clone plus an ipcRenderer.send. + const sinceReportMs = state.lastReportedAtMs === null ? null : nowMs - state.lastReportedAtMs + if (sinceReportMs !== null && sinceReportMs >= 0 && sinceReportMs < minReportIntervalMs) { + state.suppressed += 1 + return + } + reportCascade(state, pendingLanes, nowMs) +} + +/** Injectable entry point; production uses observeReactCommit. */ +export function recordReactCommit(args: { + state: ReactCommitCascadeState + root: unknown + pendingLanes: number + readNowMs: () => number + noticeLimit?: number + armCommits?: number + minReportIntervalMs?: number +}): void { + recordCommit( + args.state, + args.root, + args.pendingLanes, + args.readNowMs, + args.noticeLimit ?? REACT_COMMIT_CASCADE_NOTICE_LIMIT, + args.armCommits ?? REACT_COMMIT_CASCADE_ARM_COMMITS, + args.minReportIntervalMs ?? REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS + ) +} + +/** + * Per-commit hot path: one property read, one mask, two compares, one + * increment. No clock read and no allocation until a cascade arms. + */ +export function observeReactCommit(root: unknown, pendingLanes: number): void { + recordCommit( + sharedState, + root, + pendingLanes, + Date.now, + REACT_COMMIT_CASCADE_NOTICE_LIMIT, + REACT_COMMIT_CASCADE_ARM_COMMITS, + REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS + ) +} diff --git a/src/renderer/src/lib/react-devtools-commit-hook-shim.ts b/src/renderer/src/lib/react-devtools-commit-hook-shim.ts new file mode 100644 index 00000000000..7afde839020 --- /dev/null +++ b/src/renderer/src/lib/react-devtools-commit-hook-shim.ts @@ -0,0 +1,70 @@ +/** + * Guarantees __REACT_DEVTOOLS_GLOBAL_HOOK__ exists, and nothing else. + * + * Split out from the observer because the two halves have different deadlines: + * react-dom reads this global ONCE, at its own module evaluation, so a fresh + * shim must beat that import — but wrapping `onCommitFiberRoot` can happen at + * any time, since react-dom re-reads the property at every commit. This module + * therefore installs itself on import and has NO imports of its own, so a bare + * side-effect import cannot be outrun by a transitive graph that reaches + * react-dom, and cannot drag the breadcrumb recorder in ahead of any vi.mock. + */ + +/** + * Only what react-dom and react-refresh require. onCommitFiberUnmount, + * onPostCommitFiberRoot and setStrictMode are deliberately absent: react-dom + * guards each with a typeof check, and calls onCommitFiberUnmount once per + * DELETED FIBER, which is genuinely hot when terminal panes unmount. + */ +export type ReactDevtoolsCommitHook = { + isDisabled?: boolean + supportsFiber?: boolean + renderers?: Map + inject?: (renderer: unknown) => number + onCommitFiberRoot?: ( + rendererId: number, + root: unknown, + priorityLevel: unknown, + didError: boolean + ) => void +} + +// Indexed, not a global augmentation: bippy (a react-grab dependency) already +// declares this global with a narrower callback signature. +type GlobalWithReactDevtoolsHook = Record + +function createCommitHookShim(): ReactDevtoolsCommitHook { + const renderers = new Map() + return { + isDisabled: false, + supportsFiber: true, + renderers, + inject: (renderer) => { + const rendererId = renderers.size + 1 + renderers.set(rendererId, renderer) + return rendererId + }, + // Why a noop and not undefined: react-refresh captures this property and + // calls oldOnCommitFiberRoot.apply(this, arguments) with no typeof guard. + onCommitFiberRoot: () => {} + } +} + +/** Returns the existing hook untouched, or installs a minimal one. */ +export function ensureReactDevtoolsCommitHook(): ReactDevtoolsCommitHook | undefined { + try { + const globalWithHook = globalThis as unknown as GlobalWithReactDevtoolsHook + const existing = globalWithHook.__REACT_DEVTOOLS_GLOBAL_HOOK__ + if (existing) { + return existing + } + const hook = createCommitHookShim() + globalWithHook.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook + return hook + } catch { + // A renderer without a patchable global still has to boot. + return undefined + } +} + +ensureReactDevtoolsCommitHook() diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index cff89bd6b38..825ff536b6f 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,3 +1,9 @@ +// Why first, and why the import-free shim: react-dom reads +// __REACT_DEVTOOLS_GLOBAL_HOOK__ once at module evaluation, so the global has to +// exist before it. The observer below only wraps a property react-dom re-reads +// per commit, so its own import graph can evaluate whenever it likes. +import './lib/react-devtools-commit-hook-shim' +import './lib/react-commit-cascade-observer' import './assets/main.css' import { StrictMode } from 'react' diff --git a/src/renderer/src/popout.tsx b/src/renderer/src/popout.tsx index 00470df1374..3c84112d1a3 100644 --- a/src/renderer/src/popout.tsx +++ b/src/renderer/src/popout.tsx @@ -1,3 +1,9 @@ +// Why first, and why the import-free shim: react-dom reads +// __REACT_DEVTOOLS_GLOBAL_HOOK__ once at module evaluation, so the global has to +// exist before it. The observer below only wraps a property react-dom re-reads +// per commit, so its own import graph can evaluate whenever it likes. +import './lib/react-devtools-commit-hook-shim' +import './lib/react-commit-cascade-observer' import './assets/main.css' import { StrictMode, useEffect } from 'react' @@ -15,6 +21,7 @@ import { translate } from './i18n/i18n' import { useAppStore } from './store' import type { GlobalSettings } from '../../shared/global-settings-types' import { getOrCreateRendererRoot } from './lib/react-renderer-root' +import { setReactCommitCascadeRendererSurface } from './lib/react-commit-cascade-telemetry' // Why: the pop-out window is a separate BrowserWindow with its own React root, // so it must run the same renderer bootstrap as main.tsx (crash diagnostics, @@ -22,6 +29,7 @@ import { getOrCreateRendererRoot } from './lib/react-renderer-root' // window. It shares the preload/window.api but not the DOM or JS context. recordRendererCrashBreadcrumb('popout_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics('dashboard-popout') +setReactCommitCascadeRendererSurface('dashboard-popout') function applyPopoutAppearance(settings: GlobalSettings | null): void { applyDocumentTheme(settings?.theme ?? 'system', { disableTransitions: false }) diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 3fd5b308585..35bc1845736 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -52,61 +52,64 @@ import { registerRuntimeHttpLinkBrowserOpener } from '@/lib/http-link-routing' import { installStoreListenerCensus } from './store-listener-census' +import { withReactCommitCascadeWriteProbe } from './react-commit-cascade-write-probe' import { registerRendererMemoryProfileContributor, summarizeStateCollectionSizes } from '@/lib/renderer-memory-profile' import { estimateStateCollectionKB } from '@/lib/state-collection-byte-estimate' -export const useAppStore = create()((...a) => { - // Why: the inner api is only reachable here, before create() copies subscribe onto the hook. - installStoreListenerCensus(a[2]) - return { - ...createRepoSlice(...a), - ...createSparsePresetsSlice(...a), - ...createWorktreeSlice(...a), - ...createTerminalSlice(...a), - ...createTabsSlice(...a), - ...createUISlice(...a), - ...createSettingsSlice(...a), - ...createKeybindingsSlice(...a), - ...createGitHubSlice(...a), - ...createHostedReviewSlice(...a), - ...createLinearSlice(...a), - ...createPreflightSlice(...a), - ...createJiraSlice(...a), - ...createEditorSlice(...a), - ...createStatsSlice(...a), - ...createMemorySlice(...a), - ...createWorkspaceSpaceSlice(...a), - ...createClaudeUsageSlice(...a), - ...createCodexUsageSlice(...a), - ...createOpenCodeUsageSlice(...a), - ...createBrowserSlice(...a), - ...createRateLimitSlice(...a), - ...createSshSlice(...a), - ...createRuntimeEnvironmentSshSlice(...a), - ...createAgentStatusSlice(...a), - ...createPaneForegroundAgentSlice(...a), - ...createDiffCommentsSlice(...a), - ...createDetectedAgentsSlice(...a), - ...createRuntimeDetectedAgentsSlice(...a), - ...createWorktreeNavHistorySlice(...a), - ...createDictationSlice(...a), - ...createWorkspaceCleanupSlice(...a), - ...createWorkspaceCleanupBrowseSlice(...a), - ...createRuntimeStatusSlice(...a), - ...createPullRequestGenerationSlice(...a), - ...createCommitMessageGenerationSlice(...a), - ...createPinnedTabCloseConfirmSlice(...a), - ...createRecentlyClosedTabsSlice(...a), - ...createOrcaProfilesSlice(...a), - ...createNewIssueDraftSlice(...a), - ...createTaskCreationDraftsSlice(...a), - ...createRemoteServerUpdatesSlice(...a), - ...createTerminalQuickCommandHostsSlice(...a) - } -}) +export const useAppStore = create()( + withReactCommitCascadeWriteProbe((...a) => { + // Why: the inner api is only reachable here, before create() copies subscribe onto the hook. + installStoreListenerCensus(a[2]) + return { + ...createRepoSlice(...a), + ...createSparsePresetsSlice(...a), + ...createWorktreeSlice(...a), + ...createTerminalSlice(...a), + ...createTabsSlice(...a), + ...createUISlice(...a), + ...createSettingsSlice(...a), + ...createKeybindingsSlice(...a), + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a), + ...createLinearSlice(...a), + ...createPreflightSlice(...a), + ...createJiraSlice(...a), + ...createEditorSlice(...a), + ...createStatsSlice(...a), + ...createMemorySlice(...a), + ...createWorkspaceSpaceSlice(...a), + ...createClaudeUsageSlice(...a), + ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), + ...createBrowserSlice(...a), + ...createRateLimitSlice(...a), + ...createSshSlice(...a), + ...createRuntimeEnvironmentSshSlice(...a), + ...createAgentStatusSlice(...a), + ...createPaneForegroundAgentSlice(...a), + ...createDiffCommentsSlice(...a), + ...createDetectedAgentsSlice(...a), + ...createRuntimeDetectedAgentsSlice(...a), + ...createWorktreeNavHistorySlice(...a), + ...createDictationSlice(...a), + ...createWorkspaceCleanupSlice(...a), + ...createWorkspaceCleanupBrowseSlice(...a), + ...createRuntimeStatusSlice(...a), + ...createPullRequestGenerationSlice(...a), + ...createCommitMessageGenerationSlice(...a), + ...createPinnedTabCloseConfirmSlice(...a), + ...createRecentlyClosedTabsSlice(...a), + ...createOrcaProfilesSlice(...a), + ...createNewIssueDraftSlice(...a), + ...createTaskCreationDraftsSlice(...a), + ...createRemoteServerUpdatesSlice(...a), + ...createTerminalQuickCommandHostsSlice(...a) + } + }) +) registerHttpLinkStoreAccessor(() => useAppStore.getState()) registerRuntimeHttpLinkBrowserOpener(async (request) => { diff --git a/src/renderer/src/store/react-commit-cascade-write-probe.test.ts b/src/renderer/src/store/react-commit-cascade-write-probe.test.ts new file mode 100644 index 00000000000..0c18706e976 --- /dev/null +++ b/src/renderer/src/store/react-commit-cascade-write-probe.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create, type StoreApi, type UseBoundStore } from 'zustand' +import { withReactCommitCascadeWriteProbe } from './react-commit-cascade-write-probe' + +const { probe, noteWrite } = vi.hoisted(() => ({ + probe: { armed: false }, + noteWrite: vi.fn() +})) +vi.mock('@/lib/react-commit-cascade-store-write-samples', () => ({ + reactCommitCascadeWriteProbe: probe, + noteReactCommitCascadeStoreWrite: (boundary: object, partial: unknown) => + noteWrite(boundary, partial) +})) + +type ProbedState = { ticks: number; label?: string } + +function createProbedStore(): UseBoundStore> { + return create()( + withReactCommitCascadeWriteProbe(() => ({ ticks: 0, label: 'initial' })) + ) +} + +beforeEach(() => { + probe.armed = false + noteWrite.mockReset() +}) + +describe('withReactCommitCascadeWriteProbe', () => { + // Why this matters most: this wraps every write the app makes. A diagnostic + // that throws here would drop the write and take app state with it. + it('lands the write even when the sampler throws', () => { + noteWrite.mockImplementation(() => { + throw new Error('sampler exploded') + }) + const useProbedStore = createProbedStore() + probe.armed = true + + expect(() => useProbedStore.setState({ ticks: 3 })).not.toThrow() + expect(useProbedStore.getState().ticks).toBe(3) + }) + + // Why asserted: create() copies api.setState onto the hook AFTER the creator + // runs, so a middleware that forgets this line leaves store.setState unprobed. + it('routes the api setState through the probe', () => { + const useProbedStore = createProbedStore() + probe.armed = true + + useProbedStore.setState({ ticks: 1 }) + + expect(noteWrite).toHaveBeenCalledTimes(1) + // The boundary must be the wrapper itself, so V8 elides it from the sample. + expect(noteWrite.mock.calls[0]?.[0]).toBe(useProbedStore.setState) + expect(noteWrite.mock.calls[0]?.[1]).toEqual({ ticks: 1 }) + }) + + it('passes the replace flag through', () => { + const useProbedStore = createProbedStore() + probe.armed = true + + useProbedStore.setState({ ticks: 2 }, true) + + expect(useProbedStore.getState()).toEqual({ ticks: 2 }) + }) + + it('costs nothing but a flag read while unarmed', () => { + const useProbedStore = createProbedStore() + + useProbedStore.setState({ ticks: 4 }) + + expect(noteWrite).not.toHaveBeenCalled() + expect(useProbedStore.getState().ticks).toBe(4) + }) +}) diff --git a/src/renderer/src/store/react-commit-cascade-write-probe.ts b/src/renderer/src/store/react-commit-cascade-write-probe.ts new file mode 100644 index 00000000000..229c46dcb0c --- /dev/null +++ b/src/renderer/src/store/react-commit-cascade-write-probe.ts @@ -0,0 +1,37 @@ +/** + * zustand middleware that samples store writes during a suspected React commit + * cascade, so the crash report names the code driving it. + * + * Why middleware and not a setState patch: create() hands slices the `set` + * closure built before `api` exists (zustand/esm/vanilla.mjs), so patching + * useAppStore.setState afterwards misses every slice-internal set() — and the + * slices are where the writes are. Wrapping the creator catches both, the same + * way store-listener-census has to reach the inner api to see hook subscribers. + * + * Cost when no cascade is suspected: one boolean field load per write. + */ +import { + noteReactCommitCascadeStoreWrite, + reactCommitCascadeWriteProbe +} from '@/lib/react-commit-cascade-store-write-samples' +import type { StateCreator } from 'zustand' + +export function withReactCommitCascadeWriteProbe( + createState: StateCreator +): StateCreator { + return (set, get, api) => { + const wrapped = ((partial: unknown, replace?: unknown): void => { + if (reactCommitCascadeWriteProbe.armed) { + try { + noteReactCommitCascadeStoreWrite(wrapped, partial) + } catch { + // This is the app's universal write path: a diagnostic that throws here + // would drop the write itself. + } + } + ;(set as (nextPartial: unknown, nextReplace?: unknown) => void)(partial, replace) + }) as typeof set + api.setState = wrapped as typeof api.setState + return createState(wrapped, get, api) + } +} diff --git a/src/shared/react-update-depth-attribution.ts b/src/shared/react-update-depth-attribution.ts index 7d79a1d36ad..98b45d478bd 100644 --- a/src/shared/react-update-depth-attribution.ts +++ b/src/shared/react-update-depth-attribution.ts @@ -4,6 +4,13 @@ * next after the counter trips. The catching boundary is a bystander picked by commit order. */ +/** + * react-dom 19.2.x NESTED_UPDATE_LIMIT. getRootForUpdatedFiber throws once the + * root-global nested-update counter passes it. Every diagnostic that budgets + * commits against React's bail must derive its threshold from this one number. + */ +export const REACT_NESTED_UPDATE_LIMIT = 50 + export const UNRELIABLE_BOUNDARY_ATTRIBUTION = 'unreliable' export type CrashReportAttribution = typeof UNRELIABLE_BOUNDARY_ATTRIBUTION From 2c07e7c1d522b0cbca806ada66a9c56b90620cd7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:46:42 -0700 Subject: [PATCH 022/118] fix(workspace-cleanup): color review pills by PR/MR state (#16726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workspace-cleanup): color review pills by PR/MR state The inactive-workspace review dialog rendered every linked review pill in one of two flat tones, so a merged PR, a closed PR, and an unlinked row all looked alike. Reuse the state colors the PR page and item dialog already use (purple merged, rose closed, slate draft, emerald open) and give the pill the matching state glyph. The mapping lived in two byte-identical copies; both now delegate to a shared review-state module, as does the sidebar's state-icon picker. * fix(workspace-cleanup): expose review state in confirmation rows * fix(workspace-cleanup): don't repeat the review number in the pill's sr-only text The confirmation row's screen-reader span read the whole tooltip, so the PR number was announced twice. Announce only what the color carries — the translated state label and title. * refactor(github): collapse the duplicated work-item state badge The PR page and the item dialog each carried their own copy of the badge: identical markup, identical base classes, identical open-state tone. Only the closed-ISSUE tone genuinely differs, so that becomes a parameter and the rest moves to one component. Also drops a one-line tone wrapper in workspace-cleanup and folds the review tooltip onto the screen-reader text it already duplicated. --- .../work-item-state-badge.tsx | 38 ++-------- .../github/review-state-presentation.ts | 41 ++++++++++ .../github/work-item-state-badge.tsx | 48 ++++++++++++ .../presentation/state-badge.tsx | 35 ++------- .../sidebar/worktree-review-helpers.tsx | 22 +----- .../workspace-cleanup-candidate-row-data.ts | 12 +-- .../workspace-cleanup-candidate-row.test.tsx | 74 +++++++++++++++++++ .../workspace-cleanup-candidate-row.tsx | 9 ++- .../workspace-cleanup-confirm-remove.test.tsx | 39 ++++++++++ .../workspace-cleanup-confirm-remove.tsx | 8 +- .../workspace-cleanup-metadata-chip.tsx | 8 +- .../workspace-cleanup-row-labels.ts | 13 +++- .../workspace-cleanup-status-pill.tsx | 8 +- 13 files changed, 256 insertions(+), 99 deletions(-) create mode 100644 src/renderer/src/components/github/review-state-presentation.ts create mode 100644 src/renderer/src/components/github/work-item-state-badge.tsx diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx index cd2135b9873..0e05cb0803d 100644 --- a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx +++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx @@ -1,26 +1,14 @@ import React from 'react' -import { cn } from '@/lib/utils' +import { + getWorkItemStateTone, + WorkItemStateBadge as SharedWorkItemStateBadge +} from '@/components/github/work-item-state-badge' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' -import { getStateLabel } from '@/components/github/work-item-state-presentation' +// Why: closed issues stay neutral here (may be completed/resolved); red is reserved +// for PR closed-without-merge. export function getStateTone(item: GitHubWorkItem): string { - if (item.type === 'pr') { - if (item.state === 'merged') { - return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' - } - if (item.state === 'draft') { - return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' - } - if (item.state === 'closed') { - // Why: keep closed issues neutral (may be completed/resolved); red is reserved for PR closed-without-merge. - return 'border-ring/50 bg-primary/10 text-foreground' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' + return getWorkItemStateTone(item, 'neutral') } export function WorkItemStateBadge({ @@ -30,15 +18,5 @@ export function WorkItemStateBadge({ item: GitHubWorkItem className?: string }): React.JSX.Element { - return ( - - {getStateLabel(item)} - - ) + return } diff --git a/src/renderer/src/components/github/review-state-presentation.ts b/src/renderer/src/components/github/review-state-presentation.ts new file mode 100644 index 00000000000..ac9ef42629e --- /dev/null +++ b/src/renderer/src/components/github/review-state-presentation.ts @@ -0,0 +1,41 @@ +import { GitMerge, GitPullRequestClosed, GitPullRequestDraft, type LucideIcon } from 'lucide-react' +import type { HostedReviewState } from '../../../../shared/hosted-review' + +/** Every review surface renders state from here; `unknown` means we have a number but no fetched state. */ +export type ReviewStateForDisplay = HostedReviewState | 'unknown' | null | undefined + +/** Also the tone for an open issue, which reads the same as an open review. */ +export const OPEN_REVIEW_STATE_TONE = + 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' + +/** Outline-pill tone: purple merged, rose closed, slate draft, emerald open, neutral unknown. */ +export function getReviewStateTone(state: ReviewStateForDisplay): string { + if (state === 'merged') { + return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' + } + if (state === 'draft') { + return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' + } + if (state === 'closed') { + return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + } + if (state === 'open') { + return OPEN_REVIEW_STATE_TONE + } + return 'border-border bg-background text-muted-foreground' +} + +// Why: the glyph must carry review state — tone alone made a draft with failing +// checks render as a red PR icon, which reads as closed. +export function getReviewStateIcon(state: ReviewStateForDisplay): LucideIcon | null { + if (state === 'merged') { + return GitMerge + } + if (state === 'closed') { + return GitPullRequestClosed + } + if (state === 'draft') { + return GitPullRequestDraft + } + return null +} diff --git a/src/renderer/src/components/github/work-item-state-badge.tsx b/src/renderer/src/components/github/work-item-state-badge.tsx new file mode 100644 index 00000000000..4286a7a2c2d --- /dev/null +++ b/src/renderer/src/components/github/work-item-state-badge.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { getStateLabel } from '@/components/github/work-item-state-presentation' +import { + getReviewStateTone, + OPEN_REVIEW_STATE_TONE +} from '@/components/github/review-state-presentation' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' + +/** + * Whether a closed ISSUE reads as failure or as neutral completion. PR state tone + * never varies — only surfaces disagree on what a closed issue means. + */ +export type ClosedIssueTone = 'destructive' | 'neutral' + +export function getWorkItemStateTone(item: GitHubWorkItem, closedIssue: ClosedIssueTone): string { + if (item.type === 'pr') { + return getReviewStateTone(item.state) + } + if (item.state === 'closed') { + return closedIssue === 'neutral' + ? 'border-ring/50 bg-primary/10 text-foreground' + : 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + } + return OPEN_REVIEW_STATE_TONE +} + +export function WorkItemStateBadge({ + item, + className, + closedIssue +}: { + item: GitHubWorkItem + className?: string + closedIssue: ClosedIssueTone +}): React.JSX.Element { + return ( + + {getStateLabel(item)} + + ) +} diff --git a/src/renderer/src/components/pull-request-page/presentation/state-badge.tsx b/src/renderer/src/components/pull-request-page/presentation/state-badge.tsx index 62d38f67806..e50b76344e4 100644 --- a/src/renderer/src/components/pull-request-page/presentation/state-badge.tsx +++ b/src/renderer/src/components/pull-request-page/presentation/state-badge.tsx @@ -1,25 +1,12 @@ import React from 'react' -import { cn } from '@/lib/utils' -import { getStateLabel } from '@/components/github/work-item-state-presentation' +import { + getWorkItemStateTone, + WorkItemStateBadge as SharedWorkItemStateBadge +} from '@/components/github/work-item-state-badge' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' export function getStateTone(item: GitHubWorkItem): string { - if (item.type === 'pr') { - if (item.state === 'merged') { - return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' - } - if (item.state === 'draft') { - return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' + return getWorkItemStateTone(item, 'destructive') } /** Filled counterpart of `getStateTone` for high-emphasis surfaces like the PR page header. */ @@ -42,15 +29,5 @@ export function WorkItemStateBadge({ item: GitHubWorkItem className?: string }): React.JSX.Element { - return ( - - {getStateLabel(item)} - - ) + return } diff --git a/src/renderer/src/components/sidebar/worktree-review-helpers.tsx b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx index 96e64fb4423..26a16c9f629 100644 --- a/src/renderer/src/components/sidebar/worktree-review-helpers.tsx +++ b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx @@ -1,5 +1,6 @@ -import { GitMerge, GitPullRequestClosed, GitPullRequestDraft } from 'lucide-react' +import { GitMerge } from 'lucide-react' import { cn } from '@/lib/utils' +import { getReviewStateIcon } from '@/components/github/review-state-presentation' import { PullRequestIcon } from './WorktreeCardHelpers' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' @@ -23,23 +24,6 @@ export function getProviderName(review: WorktreeCardPrDisplay): string { return 'GitHub' } -// Why: the glyph must carry review state — tone alone made a draft with failing -// checks render as a red PR icon, which reads as closed. -function getStateIcon( - state: WorktreeCardPrDisplay['state'] -): React.ComponentType<{ className?: string }> | null { - if (state === 'merged') { - return GitMerge - } - if (state === 'closed') { - return GitPullRequestClosed - } - if (state === 'draft') { - return GitPullRequestDraft - } - return null -} - // Why: checks only gate a review that is actually open; draft/closed/merged keep // their state tone so the glyph agrees with its tooltip. A stateless row (folder // cards render one while a linked review is loading or its details failed) has no @@ -88,6 +72,6 @@ export function ReviewIcon({ }): React.JSX.Element { const providerIcon = variant === 'provider' && review.provider === 'gitlab' ? GitMerge : PullRequestIcon - const Icon = getStateIcon(review.state) ?? providerIcon + const Icon = getReviewStateIcon(review.state) ?? providerIcon return } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts index 75103a98d0c..1f9d3d2ce52 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -1,9 +1,6 @@ import { translate } from '@/i18n/i18n' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' -import { - getWorkspaceCleanupGitLabel, - type WorkspaceCleanupReviewInfo -} from './workspace-cleanup-presentation' +import { getWorkspaceCleanupGitLabel } from './workspace-cleanup-presentation' import { formatUnpushedCommitCount, formatWorkspaceCleanupContextDetail, @@ -172,13 +169,6 @@ function hasGitStatusPill(candidate: WorkspaceCleanupCandidate): boolean { return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.git.clean === false } -export function getReviewPillTone(reviewInfo: WorkspaceCleanupReviewInfo): StatusPillTone { - if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') { - return 'review' - } - return 'neutral' -} - export function getContextCount(candidate: WorkspaceCleanupCandidate): number { return ( candidate.localContext.terminalTabCount + diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx index d9deb16e8e9..9d4ee5a6e2f 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx @@ -247,4 +247,78 @@ describe('CandidateRow', () => { expect(container?.querySelector(`[aria-label="Select ${remoteName}"]`)).not.toBeNull() expect(container?.querySelector(`[aria-label="Host: builder"]`)).not.toBeNull() }) + + it('colors the review pill by state the way the PR page and item dialog do', () => { + const states = [ + { state: 'merged' as const, label: 'PR #1', hue: 'purple' }, + { state: 'closed' as const, label: 'PR #2', hue: 'rose' }, + { state: 'open' as const, label: 'PR #3', hue: 'emerald' }, + { state: 'draft' as const, label: 'PR #4', hue: 'slate' } + ] + + for (const { state, label, hue } of states) { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + const pill = container?.querySelector(`[aria-label*="${label}"]`) + expect(pill?.className).toContain(`text-${hue}-`) + expect(pill?.className).not.toContain('text-muted-foreground') + } + }) + + it('leaves a review with an unfetched state neutral', () => { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + const pill = container?.querySelector('[aria-label*="PR #9"]') + expect(pill?.className).toContain('text-muted-foreground') + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index bec347d570d..fd4c442d55b 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -36,10 +36,13 @@ import { getCandidateFactStatuses, getContextCount, getDirtyGitLabel, - getReviewPillTone, getWorkspaceCleanupBlockerLabels, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' +import { + getReviewStateIcon, + getReviewStateTone +} from '@/components/github/review-state-presentation' import { StatusPill } from './workspace-cleanup-status-pill' import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip' import { WorkspaceCleanupForgetLocallyButton } from './workspace-cleanup-forget-locally-button' @@ -255,10 +258,10 @@ export const CandidateRow = React.memo(function CandidateRow({ ) : null} {reviewInfo.label ? ( ) : null} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx index dbffc7214e5..379a9d60d10 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx @@ -218,4 +218,43 @@ describe('WorkspaceCleanupConfirmRemove', () => { '1 workspace currently shows risk and may need a force delete' ) }) + + it('gives the review pill a text equivalent for its state color without repeating the number', () => { + const candidate = makeFacetCandidate({ + worktreeId: 'repo-1::/repo/merged-review', + displayName: 'Merged review', + path: '/repo/merged-review' + }) + + act(() => { + root.render( + + ) + }) + + const srText = container.querySelector('span.sr-only')?.textContent ?? '' + expect(srText).toContain('Merged') + expect(srText).not.toContain('PR #15716') + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx index c7ccae4a8fe..97a31aedc5c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -15,11 +15,12 @@ import { getCandidateFactStatuses, formatContextDetailLabels, getDirtyGitLabel, - getReviewPillTone, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' +import { getReviewStateTone } from '@/components/github/review-state-presentation' +import { getReviewStateSrText } from './workspace-cleanup-row-labels' import { StatusPill } from './workspace-cleanup-status-pill' import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' import { @@ -255,7 +256,10 @@ function ConfirmRemoveRow({ ))} {reviewInfo.label ? ( - {reviewInfo.label} + + {reviewInfo.label} + {getReviewStateSrText(reviewInfo)} + ) : null} {contextPillLabels.map((label) => ( {label} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx index 5fea9257680..bae88d8fc03 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx @@ -8,12 +8,15 @@ export function WorkspaceCleanupMetadataChip({ icon: Icon, label, value, - tone = 'neutral' + tone = 'neutral', + toneClassName }: { icon: LucideIcon label: string value?: string tone?: StatusPillTone + /** Overrides `tone` for chips whose color comes from a domain state (e.g. review state). */ + toneClassName?: string }): React.JSX.Element { return ( @@ -25,7 +28,8 @@ export function WorkspaceCleanupMetadataChip({ tone === 'ready' && 'border-[color:color-mix(in_srgb,var(--git-decoration-added)_45%,transparent)] bg-[color:color-mix(in_srgb,var(--git-decoration-added)_10%,transparent)] text-[var(--git-decoration-added)]', tone === 'review' && 'bg-muted text-foreground', - tone === 'destructive' && 'border-destructive/30 text-destructive' + tone === 'destructive' && 'border-destructive/30 text-destructive', + toneClassName )} aria-label={label} > diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts index 887cb1c17d0..b7b897f1a22 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts @@ -1,9 +1,20 @@ import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' +import { getWorkspaceCleanupReviewStateLabel } from './workspace-cleanup-facet-labels' export function formatCompactActivityLabel(label: string): string { return label === 'Just now' ? 'now' : label.replace(/ ago$/, '') } export function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { - return [reviewInfo.label, reviewInfo.state, reviewInfo.title].filter(Boolean).join(' · ') + return [reviewInfo.label, getReviewStateSrText(reviewInfo)].filter(Boolean).join(' · ') +} + +/** Text equivalent for the state color on pills that render the number visibly. */ +export function getReviewStateSrText(reviewInfo: WorkspaceCleanupReviewInfo): string { + if (!reviewInfo.state) { + return '' + } + return [getWorkspaceCleanupReviewStateLabel(reviewInfo.state), reviewInfo.title] + .filter(Boolean) + .join(' · ') } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx index 1e5786fda49..0a5efdd2bdc 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx @@ -4,10 +4,13 @@ import type { StatusPillTone } from './workspace-cleanup-candidate-row-data' export function StatusPill({ children, - tone = 'neutral' + tone = 'neutral', + toneClassName }: { children: React.ReactNode tone?: StatusPillTone + /** Overrides `tone` for pills whose color comes from a domain state (e.g. review state). */ + toneClassName?: string }): React.JSX.Element { return ( {children} From 928d306b53be9284a0f1d1dcc35465b15b112795 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:48:53 -0700 Subject: [PATCH 023/118] fix(agent-hooks): stop the Windows hook launcher spelling the AV-denied flag pair (STA-5237) (#16739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-hooks): stop the Windows hook launcher spelling the AV-denied flag pair (STA-5237) `-WindowStyle Hidden` + `-EncodedCommand` is denied at CreateProcess by Kaspersky on Windows 11, whatever the payload decodes to. Bash reports it as `Permission denied` and every managed hook event fails, so agent status never arrives; the parent shell also briefly cannot spawn anything afterwards, so a denied hook can take the user's next command down with it. Measured on the reporting host (#16003), with a harmless `exit 0` payload: -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand 126 -NoProfile -WindowStyle Hidden -EncodedCommand 126 -WindowStyle Hidden -EncodedCommand 126 -NoProfile -EncodedCommand 0 (5/5) #16576 removed `-ExecutionPolicy Bypass`, which is the one flag of the three NOT in the signature, so hooks kept failing after that fix. The pair that has to stop being spelled is `-WindowStyle Hidden` + `-EncodedCommand`. Because the change is to the shared switch constant, it covers every site that spells the denied pair in one edit: Claude via `wrapWindowsPowerShellEncodedCommand`, gemini/cursor/droid/command-code/copilot via `wrapWindowsHookCommand`, the `runtime-home-hook-command` unsafe-HOME fallback, and the spaced-path fallback for codex/grok/devin/antigravity. Only a flag is removed, so parser and payload compatibility is unchanged for every executor: the string is still a PowerShell command line, still one self-contained token, still base64-shielded. The tradeoff, recorded rather than hidden: `-WindowStyle Hidden` was the shipped fix for #14815 (+#14828, #15117, #15447, #15767), and this removes it. Its suppression was never measured — #14825 confirmed it visually, #16576's author stated it "remains unverified on a real box", and #15506's author argued it cannot help a `.cmd` child with no console to inherit. The console is allocated by the parent chain, not by this command line. A live window measurement is still outstanding and is called out in the PR. Also adds `windows-hook-payload-delivery.test.ts` to the PR CI Windows leg, which had never run it. * test(agent-hooks): keep launcher token out of source grep --- .github/workflows/pr.yml | 1 + src/main/agent-hooks/installer-utils.test.ts | 4 +- .../windows-powershell-hook-launcher.test.ts | 48 ++++++++++--------- .../windows-powershell-hook-launcher.ts | 41 +++++++++------- src/main/claude/hook-service.test.ts | 8 +--- .../hook-service-managed-install.test.ts | 2 +- src/main/command-code/hook-service.test.ts | 2 +- src/main/cursor/hook-service.test.ts | 2 +- src/main/droid/hook-service.test.ts | 2 +- src/main/gemini/hook-service.test.ts | 2 +- src/main/grok/hook-service.test.ts | 2 +- 11 files changed, 61 insertions(+), 53 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0862ac31fe7..a256da5112c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -703,6 +703,7 @@ jobs: src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts src/shared/child-process/windows-command-line.win32.test.ts + src/main/agent-hooks/windows-hook-payload-delivery.test.ts src/main/windows/windows-pty-job.win32.test.ts src/main/windows/windows-host-job.win32.test.ts src/main/wsl/wsl-runner.test.ts diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 88c73550da2..3cf2525ba1e 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -604,7 +604,7 @@ describe('wrapPosixHookCommand', () => { }) const qualifiedWindowsPowerShellCommand = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ function decodeWindowsHookCommand(command: string): string { const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1] @@ -762,7 +762,7 @@ describe('wrapRuntimeHomeHookCommand', () => { // applies to the exact same string (#16003). const command = wrapRuntimeHomeHookCommand('claude-hook') - expect(command).toContain('powershell.exe" -NoProfile -WindowStyle Hidden -EncodedCommand ') + expect(command).toContain('powershell.exe" -NoProfile -EncodedCommand ') expect(command).not.toMatch(/-ExecutionPolicy/i) }) diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts index e58fd25c960..df307d87035 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { encodeWindowsPowerShellHookCommand, + getWindowsPowerShellExecutablePath, WINDOWS_POWERSHELL_HOOK_SWITCHES, wrapWindowsPowerShellEncodedCommand } from './windows-powershell-hook-launcher' @@ -12,38 +13,41 @@ function decodePayload(command: string): string { } /* - * Two reproduced Windows failures constrain this one string, in opposite - * directions: + * #16003 — endpoint security (Kaspersky, Windows 11) denies process creation + * for the encoded launcher shape whatever the payload decodes to, and no + * exclusion re-enabled it. Measured on the reporting host: the denied shape + * exits 126 with or without the policy switch and with or without `-NoProfile`, + * while `-NoProfile -EncodedCommand` alone runs 5/5. So the command line spells + * neither the policy bypass (moved into the payload by #16576) nor the denied + * window switch. * - * #16003 — endpoint security (Kaspersky Premium, Windows 11) denies process - * creation for `-ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand` - * whatever the payload decodes to, and no exclusion re-enabled it. The triple - * must stop being spelled. - * - * #14815 (+ #14828, #15117, #15447, #15767) — without `-WindowStyle Hidden` - * every hook event allocates a console that takes foreground and eats the - * user's keystrokes, and strands a visible window outright when the hook blocks - * on stdin. Window suppression must stay. - * - * Both hold only if the flag that leaves the command line is the policy bypass, - * which is the one with an exact in-payload equivalent. + * The counter-pressure is real and is NOT resolved by these assertions: + * #14815 (+ #14828, #15117, #15447, #15767) reported consoles taking + * foreground. The previous suppression switch was that fix, but its behavior + * was never measured — see the launcher's comment. These tests pin the shape + * so it cannot be restored silently; whether a console appears is a question + * for a live window measurement, which no unit test here can answer. */ describe('windows PowerShell hook launcher', () => { - it('never spells the denied flag triple on the command line', () => { + it('never spells a denied flag on the command line', () => { const command = wrapWindowsPowerShellEncodedCommand('exit 0') + const switches = command.replace(/ -EncodedCommand \S+$/, '') expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).not.toMatch(/-ExecutionPolicy/i) - expect(command.replace(/ -EncodedCommand \S+$/, '')).not.toMatch(/-ExecutionPolicy/i) + expect(switches).not.toMatch(/-ExecutionPolicy/i) + // Why: measured exit 126 for the denied switch paired with -EncodedCommand. + expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).toBe('-NoProfile') + expect(switches).toBe(`${getWindowsPowerShellExecutablePath()} -NoProfile`) }) - it('keeps hiding the console window on every hook event (#14815)', () => { - // Dropping this flag is not a cosmetic flash: the console takes foreground - // and swallows what the user is typing into Orca (#14828), and never closes - // at all when the hook blocks reading stdin (#14815). + it('pins the exact launcher shape so a flag cannot come back unnoticed', () => { + // Why this is worth a test: #16576 round 3 dropped a required launcher + // invariant by accident and updated the tests to match, so nothing caught + // it. Restoring either denied switch re-breaks every hook on an AV host. const command = wrapWindowsPowerShellEncodedCommand('exit 0') - expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).toBe('-NoProfile -WindowStyle Hidden') - expect(command).toMatch(/ -NoProfile -WindowStyle Hidden -EncodedCommand [A-Za-z0-9+/=]+$/) + expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).toBe('-NoProfile') + expect(command).toMatch(/ -NoProfile -EncodedCommand [A-Za-z0-9+/=]+$/) }) it('keeps the execution-policy bypass, in the payload where AV cannot read it', () => { diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts index aeec04380db..b9a9f6dd208 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -14,26 +14,33 @@ export function getWindowsPowerShellExecutablePath(): string { * Switches for the PowerShell that relays hook output and exit status * (#14818 — conhost does neither). * - * `-WindowStyle Hidden` stays. It is the shipped fix for #14815 and its four - * duplicates (#14828, #15117, #15447, #15767): without it Windows allocates a - * console per hook event, which steals foreground from whatever the user is - * typing into, and strands a permanently visible window whenever a hook blocks - * reading stdin (see hook-stdin-contract.ts). Those are reported, reproduced - * user-facing failures on every hook event of every managed agent. + * The command line spells no flag beyond `-NoProfile`, because AV denies the + * combinations. #16003 measured, on the reporting Kaspersky host: * - * `-ExecutionPolicy Bypass` is the flag that leaves the command line, because - * it is the only one of the three with an exact in-payload equivalent (below): - * it costs nothing to move. #16003 measured `-NoProfile -ExecutionPolicy Bypass - * -WindowStyle Hidden -EncodedCommand` as denied at CreateProcess (exit 126, - * Kaspersky Premium on Windows 11) no matter what the payload decodes to, so - * the triple has to stop being spelled; dropping the policy flag breaks it. + * -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand 126 + * -NoProfile -WindowStyle Hidden -EncodedCommand 126 + * -WindowStyle Hidden -EncodedCommand 126 + * -NoProfile -EncodedCommand 0 (5/5) + * -NoProfile -ExecutionPolicy Bypass -Command 0 (5/5) * - * What is not established: that the remaining pair clears that AV signature — - * the reporter measured no two-flag encoded shape. If it turns out not to, the - * answer is another launcher shape that still suppresses the window, not - * trading a reproduced regression for an unmeasured hope. + * The denial is at CreateProcess and is independent of the payload: `exit 0` is + * denied too, and bash reports it as `Permission denied`. So `-WindowStyle + * Hidden` + `-EncodedCommand` is the pair that has to stop being spelled. + * #16576 removed `-ExecutionPolicy Bypass` (kept in-payload below), which was + * the one flag of the three NOT in the signature — hooks kept failing. + * + * `-WindowStyle Hidden` was the shipped fix for #14815 (+#14828, #15117, + * #15447, #15767). Removing it is a real tradeoff and is recorded as such: its + * suppression was never measured — #14825 confirmed it *visually*, #16576's + * author stated it "remains unverified on a real box", and #15506's author + * argued it cannot help a `.cmd` child that has no console to inherit. The + * console is allocated by the parent chain, not by this command line. + * + * Do not restore the flag to fix a console report. That trades every hook on an + * AV host for a flicker. The answer is to shorten the interpreter chain — the + * shipped doctrine of #15520 and #15595 — or a launcher that owns no console. */ -export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile -WindowStyle Hidden' +export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile' // Why: redirected PowerShell progress becomes CLIXML that can corrupt merged JSON // output. It must be the FIRST statement: Set-ExecutionPolicy autoloads diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 315ee6dd228..531c092e3eb 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -40,9 +40,7 @@ describe('getWindowsManagedLifecycleHook', () => { const hook = getWindowsManagedLifecycleHook(scriptPath) expect(hook.args).toBeUndefined() - expect(hook.command).toMatch( - /\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand / - ) + expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /) expect(hook.command).not.toContain(scriptPath) // Why: Git Bash/MSYS mangles backslash paths and slash-prefixed switches. expect(hook.command.replace(/-EncodedCommand \S+$/, '')).not.toMatch(/\\| \/[a-zA-Z]+( |$)/) @@ -384,9 +382,7 @@ describe('ClaudeHookService.install', () => { for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] expect(hook?.args).toBeUndefined() - expect(hook?.command).toMatch( - /\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand / - ) + expect(hook?.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /) expect(hook?.command).not.toContain(scriptPath) const encoded = hook?.command.match(/-EncodedCommand (\S+)$/)?.[1] diff --git a/src/main/codex/hook-service-managed-install.test.ts b/src/main/codex/hook-service-managed-install.test.ts index 5d0021f9499..3e242dc2cf4 100644 --- a/src/main/codex/hook-service-managed-install.test.ts +++ b/src/main/codex/hook-service-managed-install.test.ts @@ -31,7 +31,7 @@ import { CodexHookService } from './hook-service' import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ const homes = setupCodexHookHomes(homedirMock, getPathMock) diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index f5b0f83af62..9765ef5b2f4 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -21,7 +21,7 @@ vi.mock('os', async () => { import { CommandCodeHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ describe('CommandCodeHookService', () => { let homeDir: string diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index 36f6aa94b48..c6aef9aefc5 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -23,7 +23,7 @@ import { CURSOR_EVENTS, type CursorEvent } from './hook-events' const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ type InstalledCursorHooks = { hooks: Record diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index bd145fdc1de..7486a12b71b 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -25,7 +25,7 @@ vi.mock('os', async () => { import { DroidHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ describe('DroidHookService', () => { let homeDir: string diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index a835a6278f8..551c40bb0b1 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -26,7 +26,7 @@ vi.mock('os', async (importOriginal) => { import { GeminiHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ describe('GeminiHookService', () => { let homeDir: string diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 4b1625614ac..251ae64fa33 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -33,7 +33,7 @@ import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ // Why (#14828): Windows registers the bare script path when it is cmd-safe and only falls back // to the encoded launcher for a profile path that is not (#6078). windows-hook-launcher-chain From 7c3bfe72d7fdba55436af410a384838550e9371c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:52:14 -0700 Subject: [PATCH 024/118] fix(windows): stop a wedged process-table reader retaining a callback per cooldown (#16696) * fix(windows): stop a wedged process-table reader retaining a callback per cooldown The vendored reader pushes every callback onto a module-global queue and drains it only when the request holding its `requestInProgress` latch completes. When a Toolhelp32 snapshot never comes back, that latch is stuck for the life of the process, so the 30 s cooldown -- which let one probe through per window -- bounded the rate of new callbacks but not the total: one more closure retained every 30 s, forever, plus a full 3 s deadline block on whichever caller drew the probe. Gate on the outstanding read instead. Once a read misses its deadline and has not called back, every further read is refused until that read's callback fires, which bounds retention at exactly one callback. Nothing is given up on recovery: a probe queued behind the latch could never have observed the drain anyway, whereas the stuck callback firing IS the drain, so the reader now resumes the instant it recovers rather than up to 30 s later. It matters more on a relay, which binds the bare addon with no JS queue to absorb the retries. Each read there is a `Napi::AsyncWorker`, so a wedged one holds a libuv threadpool slot for good and one probe per window would have pinned all four default threads inside ~2 minutes -- hanging every async `fs` and DNS call in that process, not just the process table. A wedge still does not engage the PowerShell fallback, and a wedged read still rejects rather than resolving empty, so "unavailable" stays distinguishable from "nothing is running" on every host. Fixes STA-5499. * fix(windows): invalidate stale reader deadlines on reset --- docs/reference/windows-process-enumeration.md | 34 +++++++ .../windows/windows-process-table.test.ts | 90 +++++++++++++++---- src/main/windows/windows-process-table.ts | 59 ++++++------ 3 files changed, 133 insertions(+), 50 deletions(-) diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index 115cc7040a9..e4c91329c46 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -46,6 +46,40 @@ risk — but the headroom is roughly 2x on time and 1.7x on bytes, not the ~4x t parse, and the read rejects, so a busy host loses the table rather than receiving a wrong one. +## When a read wedges + +The vendored reader pushes every callback onto a module-global queue and drains +that queue only when the request holding its `requestInProgress` latch +completes. If a Toolhelp32 snapshot never comes back — an EDR hook, a restricted +token, a worker that dies — the latch is stuck for the life of the process and +every later call parks another closure in that queue. + +Two guards, and they work together: + +- a **3 s deadline** on each read, so a caller gets a rejection instead of a + promise that never settles; +- a **sticky wedge**: once a read misses its deadline and has not called back, + the module refuses every further read until that read's callback fires. + +The wedge used to be a 30 s cooldown that let one probe through per window. That +bounded the *rate* of new callbacks but not the total: a permanently wedged +reader retained one more closure every 30 s for as long as the app ran, and each +probe also blocked its caller for the full 3 s deadline first. Gating on the +outstanding read instead bounds retention at exactly one callback, and gives up +nothing on recovery — a probe queued behind the latch could never have observed +recovery anyway, whereas the stuck callback firing is the drain itself. On the +relay's bare addon, which has no queue of its own, it is also what keeps Orca +from re-entering `CreateToolhelp32Snapshot` while a call is still running. + +That last part is not just tidiness. The addon runs each read as a +`Napi::AsyncWorker`, so a wedged read holds a libuv threadpool slot for good. On +the relay the JS queue is not there to absorb the retries, so one probe per +window would have pinned all four default threads inside ~2 minutes — hanging +every async `fs` and DNS call in that process, not only the process table. + +A wedge does **not** engage the PowerShell fallback; see the next section for +why only absence does. + ## The relay has no binding, and falls back Relay deployment installs only `node-pty` and `@parcel/watcher` on the remote diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 919a80deb1c..8bbd0ed7ccb 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -184,7 +184,7 @@ describe('PowerShell fallback when the native binding is absent', () => { }) }) -describe('wedge cooldown', () => { +describe('sticky wedge', () => { let platform: PropertyDescriptor | undefined beforeEach(() => { @@ -204,7 +204,7 @@ describe('wedge cooldown', () => { // The vendored reader latches a global while a request is in flight and // drains its queue only when that request completes. In the wedge this // guards against it never does, so every retry would add a closure that is - // never called. One probe per cooldown bounds that. + // never called. vi.useFakeTimers() const getAllProcesses = vi.fn(() => {}) __setWindowsProcessTreeLoaderForTests(() => ({ @@ -218,15 +218,15 @@ describe('wedge cooldown', () => { await firstAssertion expect(getAllProcesses).toHaveBeenCalledTimes(1) - await expect(readWindowsProcessTableFresh()).rejects.toThrow(/cooling down/) + await expect(readWindowsProcessTableFresh()).rejects.toThrow(/wedged/) expect(getAllProcesses).toHaveBeenCalledTimes(1) }) - it('lets exactly one caller probe when the cooldown expires', async () => { - // Without claiming the recovery slot before probing, every concurrent - // caller passes the cooldown check at expiry and each enqueues a callback - // into the still-latched native queue -- so each cycle leaks another batch - // rather than bounding it to one probe. + it('never re-enters the reader while the timed-out read is still out', async () => { + // A cooldown bounds the RATE of new callbacks, not the total: one probe per + // window still retains one more closure in the still-latched native queue + // every window, for the life of the app. Only the outstanding read can + // reopen the gate, so a permanent wedge retains exactly one callback. vi.useFakeTimers() const getAllProcesses = vi.fn(() => {}) __setWindowsProcessTreeLoaderForTests(() => ({ @@ -240,16 +240,72 @@ describe('wedge cooldown', () => { await wedgeAssertion expect(getAllProcesses).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(30_000) - const attempts = [ - readWindowsProcessTableFresh().catch(() => 'rejected'), - readWindowsProcessTableFresh().catch(() => 'rejected'), - readWindowsProcessTableFresh().catch(() => 'rejected') - ] - await vi.advanceTimersByTimeAsync(3_000) - await Promise.all(attempts) + // Four windows of the cooldown this replaced, each with concurrent callers. + // Rejection reasons are deliberately not asserted here: the leak this test + // pins is the call count, and it must fail on that alone. + for (let window = 0; window < 4; window += 1) { + await vi.advanceTimersByTimeAsync(30_000) + const attempts = [ + readWindowsProcessTableFresh().catch(() => 'rejected'), + readWindowsProcessTableFresh().catch(() => 'rejected'), + readWindowsProcessTableFresh().catch(() => 'rejected') + ] + // Long enough for a probe's own deadline, had one been let through. + await vi.advanceTimersByTimeAsync(3_000) + expect(await Promise.all(attempts)).toEqual(['rejected', 'rejected', 'rejected']) + } - // One recovery probe, not three. + // Still the one callback the wedge is holding -- not one more per window. + expect(getAllProcesses).toHaveBeenCalledTimes(1) + }) + + it('resumes as soon as the timed-out read finally calls back', async () => { + // The stuck request completing is what drains the vendored queue, so it is + // the only honest evidence the reader recovered. Waiting out a wall clock + // afterwards would strand a reader that is already answering. + vi.useFakeTimers() + let stuck: ((rows: typeof NATIVE | undefined) => void) | undefined + const getAllProcesses = vi.fn((cb: (rows: typeof NATIVE | undefined) => void) => { + if (stuck) { + cb(NATIVE) + return + } + stuck = cb + }) + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses + })) + + const wedge = readWindowsProcessTableFresh() + const wedgeAssertion = expect(wedge).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(3_000) + await wedgeAssertion + await expect(readWindowsProcessTableFresh()).rejects.toThrow(/wedged/) + + stuck?.(NATIVE) + await expect(readWindowsProcessTableFresh()).resolves.toHaveLength(NATIVE.length) + expect(getAllProcesses).toHaveBeenCalledTimes(2) + }) + + it('ignores a pending timeout from before a test reset', async () => { + vi.useFakeTimers() + const getAllProcesses = vi.fn((_cb: (rows: typeof NATIVE | undefined) => void) => {}) + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses + })) + + const staleRead = readWindowsProcessTableFresh() + const staleAssertion = expect(staleRead).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(0) + expect(getAllProcesses).toHaveBeenCalledTimes(1) + resetWindowsProcessTableForTests() + await vi.advanceTimersByTimeAsync(3_000) + await staleAssertion + + getAllProcesses.mockImplementation((cb) => cb(NATIVE)) + await expect(readWindowsProcessTableFresh()).resolves.toHaveLength(NATIVE.length) expect(getAllProcesses).toHaveBeenCalledTimes(2) }) diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index e03ecee6e29..35dab09d100 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -146,20 +146,18 @@ function loadWindowsProcessTree(): WindowsProcessTreeModule | null { const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000 /** - * How long to stop calling the reader after it misses its deadline. - * - * Why a cooldown and not just the deadline: a timed-out call leaves its - * callback in the vendored module's queue, and that queue only drains when the - * latched request finally completes -- which, in the wedge this guards against, - * never happens. Retrying at the caller's poll rate would then add a closure - * per tick forever. One probe per cooldown bounds it. + * Reads that missed their deadline and have not called back yet. + * Refusing re-entry bounds both vendored callbacks and relay addon workers to + * one; read ids keep a late callback from clearing a newer wedge. */ -const WINDOWS_PROCESS_QUERY_COOLDOWN_MS = 30_000 +const unreturnedReads = new Set() +let readSequence = 0 +let nativeReaderEpoch = 0 -let wedgedUntilMs = 0 -// Why a generation: a request that already lost its deadline must not later -// clear or re-arm the wedge on behalf of the request that replaced it. -let readGeneration = 0 +function resetNativeReaderState(): void { + nativeReaderEpoch += 1 + unreturnedReads.clear() +} function readNativeRows(): Promise { const native = moduleLoader() @@ -177,19 +175,13 @@ function readNativeRows(): Promise { // tree dead. "Unavailable" has to stay distinguishable from "empty". return Promise.reject(new Error('windows process table unavailable')) } - const startedAt = Date.now() - if (startedAt < wedgedUntilMs) { - return Promise.reject(new Error('windows process table is cooling down after a timeout')) + if (unreturnedReads.size > 0) { + return Promise.reject( + new Error('windows process table is wedged: an earlier read has not returned') + ) } - if (wedgedUntilMs > 0) { - // Coming out of a wedge: re-arm the cooldown BEFORE probing, so exactly one - // caller gets through. Without this every concurrent caller passes the - // check above at expiry, each enqueues a callback into the still-latched - // native queue, and each cooldown cycle leaks another batch rather than - // bounding it to one probe. - wedgedUntilMs = startedAt + WINDOWS_PROCESS_QUERY_COOLDOWN_MS - } - const generation = ++readGeneration + const readId = ++readSequence + const readerEpoch = nativeReaderEpoch // Why always both flags: each adds an OpenProcess per process (Memory a // GetProcessMemoryInfo, CommandLine a PEB read), so asking for less would be // cheaper -- 15.9ms p50 versus 30.6ms at 1050 processes. But every read shares @@ -204,18 +196,19 @@ function readNativeRows(): Promise { let deadline: ReturnType | undefined try { deadline = setTimeout(() => { - if (generation === readGeneration) { - wedgedUntilMs = Date.now() + WINDOWS_PROCESS_QUERY_COOLDOWN_MS + // Test resets invalidate deadlines owned by the prior injected reader. + if (readerEpoch === nativeReaderEpoch) { + unreturnedReads.add(readId) } reject(new Error('windows process table timed out')) }, WINDOWS_PROCESS_QUERY_TIMEOUT_MS) deadline.unref?.() native.getAllProcesses((processes) => { clearTimeout(deadline) - // A callback proves the reader is answering, so stop refusing. - if (generation === readGeneration) { - wedgedUntilMs = 0 - } + // A callback proves this read drained, so stop refusing. Unconditional: + // dropping an id that was never added is a no-op, and only the read + // that actually wedged can be holding the gate shut. + unreturnedReads.delete(readId) if (!processes) { reject(new Error('windows process table returned no snapshot')) return @@ -303,7 +296,7 @@ export function __setWindowsProcessTreeLoaderForTests( ): void { moduleLoader = loader ?? loadWindowsProcessTree cachedModule = undefined - wedgedUntilMs = 0 + resetNativeReaderState() snapshotReader.reset() } @@ -314,7 +307,7 @@ export function __setWindowsProcessTreeRequireForTests( requireNative = resolve ?? requireFromMain moduleLoader = loadWindowsProcessTree cachedModule = undefined - wedgedUntilMs = 0 + resetNativeReaderState() snapshotReader.reset() } @@ -330,5 +323,5 @@ export function __setWindowsProcessTableCimScanForTests( export function resetWindowsProcessTableForTests(): void { snapshotReader.reset() cachedModule = undefined - wedgedUntilMs = 0 + resetNativeReaderState() } From fecdf0bde8850b81403df4609fbc98d0e805f31f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:13:32 -0700 Subject: [PATCH 025/118] fix(daemon): recover concurrent spawns after disconnect (#16818) --- src/main/daemon/daemon-endpoint-errors.ts | 4 + ...on-pty-adapter-concurrent-recovery.test.ts | 78 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts diff --git a/src/main/daemon/daemon-endpoint-errors.ts b/src/main/daemon/daemon-endpoint-errors.ts index 663b11ff3fa..b792f0e7044 100644 --- a/src/main/daemon/daemon-endpoint-errors.ts +++ b/src/main/daemon/daemon-endpoint-errors.ts @@ -1,4 +1,5 @@ import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import { DaemonConnectionLostError } from './daemon-errors' import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE } from './types' /** @@ -17,6 +18,9 @@ export function isDaemonGoneError(err: unknown): boolean { if (!(err instanceof Error)) { return false } + if (err instanceof DaemonConnectionLostError) { + return true + } const errno = err as NodeJS.ErrnoException if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') { return true diff --git a/src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts b/src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts new file mode 100644 index 00000000000..12805c0b3ee --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts @@ -0,0 +1,78 @@ +import { expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonServer } from './daemon-server' +import { createMockSubprocess } from './daemon-pty-adapter-test-harness' +import { getDaemonSocketPath } from './daemon-spawner' + +it('recovers concurrent spawns rejected by an adapter-initiated disconnect', async () => { + const concurrentSpawnCount = 70 + const dir = mkdtempSync(join(tmpdir(), 'daemon-concurrent-recovery-test-')) + const socketPath = getDaemonSocketPath(dir) + const tokenPath = join(dir, 'test.token') + const allPreparationsEntered = Promise.withResolvers() + const releaseHeldPreparations = Promise.withResolvers() + const spawnedSessionIds: string[] = [] + let preparationCount = 0 + + const spawnSubprocess: ConstructorParameters[0]['spawnSubprocess'] = ( + options + ) => { + spawnedSessionIds.push(options.sessionId) + return createMockSubprocess() + } + let server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess, + // Hold siblings until one retriable reply makes the adapter disconnect their shared socket. + preparePtySpawn: async () => { + const preparationNumber = ++preparationCount + if (preparationCount === concurrentSpawnCount) { + allPreparationsEntered.resolve() + } + await allPreparationsEntered.promise + if (preparationNumber === 1) { + throw new Error('Connection lost') + } + await releaseHeldPreparations.promise + } + }) + await server.start() + + const respawn = vi.fn(async () => { + await server.shutdown() + releaseHeldPreparations.resolve() + server = new DaemonServer({ socketPath, tokenPath, spawnSubprocess }) + await server.start() + }) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + + try { + const sessionIds = Array.from( + { length: concurrentSpawnCount }, + (_, index) => `high-volume-${index}` + ) + const results = await Promise.all( + sessionIds.map((sessionId) => adapter.spawn({ sessionId, cols: 80, rows: 24 })) + ) + + expect(preparationCount).toBe(concurrentSpawnCount) + expect(respawn).toHaveBeenCalledTimes(1) + expect(respawn).toHaveBeenCalledWith('daemon_died') + expect(new Set(results.map(({ id }) => id))).toEqual(new Set(sessionIds)) + expect(new Set(spawnedSessionIds)).toEqual(new Set(sessionIds)) + + const later = await adapter.spawn({ sessionId: 'after-recovery', cols: 80, rows: 24 }) + expect(later.id).toBe('after-recovery') + expect(respawn).toHaveBeenCalledTimes(1) + expect(spawnedSessionIds).toHaveLength(concurrentSpawnCount + 1) + } finally { + adapter.dispose() + releaseHeldPreparations.resolve() + await server.shutdown() + rmSync(dir, { recursive: true, force: true }) + } +}) From cc384c5a3d12508684156dd7ce5d3eaff121380f Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Fri, 28 Aug 2026 03:32:29 +0800 Subject: [PATCH 026/118] fix(agent-hooks): post posix payloads as json (#11292) * fix(agent-hooks): post posix payloads as json * fix(agent-hooks): mark header merged envelopes * docs(agent-hooks): describe header merge envelope * fix(agent-hooks): encode posix metadata headers * test(agent-hooks): update WSL JSON hook assertions * fix(agent-hooks): negotiate raw JSON transport * fix(agent-hooks): preserve packed metadata in POSIX shells * test(agent-hooks): include hook envelope in relay boundary inventory --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- config/tsconfig.cli.json | 1 + src/main/agent-hooks/hook-post-command.ts | 37 +++++++++ src/main/agent-hooks/installer-utils.test.ts | 21 +++++ .../server-endpoint-file-lifecycle.test.ts | 1 + .../server-hook-http-ingest.test.ts | 32 +++++++ src/main/agent-hooks/server.ts | 17 ++-- src/main/claude/hook-service.test.ts | 10 ++- src/main/claude/hook-service.ts | 19 ++--- .../codex/hook-service-wsl-runtime.test.ts | 6 ++ src/main/codex/hook-service.ts | 22 ++--- src/main/ipc/pty/host-env/spawn-env-keys.ts | 1 + src/main/pty/wsl-orca-env.test.ts | 2 + src/main/pty/wsl-orca-env.ts | 1 + src/main/runtime/orca-runtime.ts | 1 + src/relay/agent-hook-endpoint-coordinates.ts | 8 +- src/relay/agent-hook-server.test.ts | 40 ++++++--- src/relay/agent-hook-server.ts | 22 +++-- ...ent-hook-listener-relay-dependency.test.ts | 1 + .../agent-hook-listener-transport.test.ts | 81 +++++++++++++++++- .../endpoint-publication.ts | 4 + .../agent-hook-listener/hook-envelope.ts | 83 +++++++++++++++++++ src/shared/agent-hook-types.ts | 3 + 22 files changed, 356 insertions(+), 57 deletions(-) create mode 100644 src/main/agent-hooks/hook-post-command.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f4555ae4a48..cec4e80cff5 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -5,6 +5,7 @@ "../src/shared/**/*", "../src/main/agent-state-file-reader.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", + "../src/main/agent-hooks/hook-post-command.ts", "../src/main/agent-hooks/hook-config-write-path.ts", "../src/main/agent-hooks/hooks-json-read.ts", "../src/main/agent-hooks/installer-utils.ts", diff --git a/src/main/agent-hooks/hook-post-command.ts b/src/main/agent-hooks/hook-post-command.ts new file mode 100644 index 00000000000..1595e681899 --- /dev/null +++ b/src/main/agent-hooks/hook-post-command.ts @@ -0,0 +1,37 @@ +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { ORCA_HOOK_RAW_JSON_TRANSPORT } from '../../shared/agent-hook-types' + +export function buildPosixAgentHookPostCommand( + source: AgentHookSource, + options: { curlCommand?: string; indent?: string } = {} +): string[] { + const curlCommand = options.curlCommand ?? 'curl' + const indent = options.indent ?? ' ' + return [ + `if [ "\${ORCA_AGENT_HOOK_TRANSPORT:-}" = "${ORCA_HOOK_RAW_JSON_TRANSPORT}" ] && command -v base64 >/dev/null 2>&1 && command -v tr >/dev/null 2>&1; then`, + ` orca_hook_metadata=$(printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_AGENT_LAUNCH_TOKEN" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" | base64 | tr -d '\\n') && \\`, + ` [ -n "$orca_hook_metadata" ] && \\`, + ` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`, + ` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`, + ` ${indent}--noproxy "127.0.0.1" \\`, + ` ${indent}-H "Content-Type: application/json" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Meta-Encoding: base64" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Meta: \${orca_hook_metadata}" \\`, + ` ${indent}--data-binary @-`, + 'else', + ` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`, + ` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`, + ` ${indent}--noproxy "127.0.0.1" \\`, + ` ${indent}-H "Content-Type: application/x-www-form-urlencoded" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`, + ` ${indent}--data-urlencode "paneKey=\${ORCA_PANE_KEY}" \\`, + ` ${indent}--data-urlencode "tabId=\${ORCA_TAB_ID}" \\`, + ` ${indent}--data-urlencode "launchToken=\${ORCA_AGENT_LAUNCH_TOKEN}" \\`, + ` ${indent}--data-urlencode "worktreeId=\${ORCA_WORKTREE_ID}" \\`, + ` ${indent}--data-urlencode "env=\${ORCA_AGENT_HOOK_ENV}" \\`, + ` ${indent}--data-urlencode "version=\${ORCA_AGENT_HOOK_VERSION}" \\`, + ` ${indent}--data-urlencode "payload@-"`, + 'fi' + ] +} diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 3cf2525ba1e..215979206e9 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -30,6 +30,7 @@ import { writeHooksJson, type HooksConfig } from './installer-utils' +import { buildPosixAgentHookPostCommand } from './hook-post-command' import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' @@ -882,6 +883,26 @@ describe('buildWindowsAgentHookPostCommand', () => { }) }) +describe('buildPosixAgentHookPostCommand', () => { + it('uses raw JSON only when the listener advertises support', () => { + const command = buildPosixAgentHookPostCommand('claude').join('\n') + + expect(command).toContain('ORCA_AGENT_HOOK_TRANSPORT:-}') + expect(command).toContain('raw-json-v1') + expect(command).toContain('command -v base64') + expect(command).toContain('command -v tr') + expect(command).toContain('Content-Type: application/json') + expect(command).toContain('X-Orca-Agent-Hook-Meta-Encoding: base64') + expect(command).toContain('X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}') + expect(command).toContain("printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s'") + expect(command).toContain('$ORCA_PANE_KEY') + expect(command).toContain('$ORCA_WORKTREE_ID') + expect(command).toContain('--data-binary @-') + expect(command).toContain('Content-Type: application/x-www-form-urlencoded') + expect(command).toContain('--data-urlencode "payload@-"') + }) +}) + describe('buildWindowsAgentHookCurlPostCommand', () => { it('posts form fields via curl.exe and reads the payload from stdin', () => { const command = buildWindowsAgentHookCurlPostCommand('codex') diff --git a/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts b/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts index b567e726695..91b7c495532 100644 --- a/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts +++ b/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts @@ -66,6 +66,7 @@ describe('Endpoint file lifecycle', () => { expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TOKEN=${expectedToken}`) expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_ENV=development`) expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_VERSION=1`) + expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1`) } finally { server.stop() } diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 55f2c6e1007..f89dcebd005 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -44,6 +44,38 @@ async function postClaudeHook( } describe('AgentHookServer listener replay', () => { + it('accepts raw JSON hook bodies with base64 metadata headers', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN, + 'X-Orca-Agent-Hook-Meta-Encoding': 'base64', + 'X-Orca-Agent-Hook-Meta': Buffer.from( + [PANE, 'tab-1', '', 'wt-1', 'production', ''].join('\x1f') + ).toString('base64') + }, + body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'raw JSON' }) + }) + + expect(response.status).toBe(204) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + worktreeId: 'wt-1', + state: 'working', + prompt: 'raw JSON' + }) + ]) + } finally { + server.stop() + } + }) + it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index ba29d3bc211..311ea277879 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -8,7 +8,10 @@ import { join } from 'node:path' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events' -import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../../shared/agent-hook-types' import { clearAllListenerCaches, clearPaneCacheState, @@ -46,6 +49,7 @@ import { } from '../../shared/agent-hook-listener/listener-limits' import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { mergeAgentHookRequestHeaders } from '../../shared/agent-hook-listener/hook-envelope' import { parseFormEncodedBody, readRequestBody @@ -2616,8 +2620,9 @@ export class AgentHookServer { return } - trackEmptyPaneKeyHook(body) - const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body) + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + trackEmptyPaneKeyHook(hookBody) + const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) const normalized = this.normalizeLocalHookPayload(source, aliasedBody) const statusDisposition = normalized.event ? this.getAgentStatusDisposition(normalized.event.paneKey, { @@ -3129,7 +3134,8 @@ export class AgentHookServer { ORCA_AGENT_HOOK_PORT: String(this.port), ORCA_AGENT_HOOK_TOKEN: this.token, ORCA_AGENT_HOOK_ENV: this.env, - ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, + ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT } // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. if (this.endpointFileWritten && this.endpointFilePathCache) { @@ -3156,7 +3162,8 @@ export class AgentHookServer { port: this.port, token: this.token, env: this.env, - version: ORCA_HOOK_PROTOCOL_VERSION + version: ORCA_HOOK_PROTOCOL_VERSION, + transport: ORCA_HOOK_RAW_JSON_TRANSPORT }) this.endpointFileWritten = ok } diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 531c092e3eb..e2937015bea 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -539,12 +539,14 @@ describe('ClaudeHookService.installRemote', () => { expect(script!.indexOf('printf "{}\\n"')).toBe( script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length ) - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive), - // matching the Windows curl.exe hook post. + // Why: payload stays on stdin, while metadata headers avoid URL-encoded IDS signatures. expect(script).toContain('printf \'%s\' "$payload" | curl') + expect(script).toContain('-H "Content-Type: application/json"') + expect(script).toContain('orca_hook_metadata=$(printf') + expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT') + expect(script).toContain('-H "X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}"') + expect(script).toContain('--data-binary @-') expect(script).toContain('--data-urlencode "payload@-"') - expect(script).not.toContain('--data-urlencode "payload=${payload}"') expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) // Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay // listener doesn't route /statusline/claude and whose accounts aren't attributable locally. diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 3dba1121a20..b3ae8d01136 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -9,6 +9,7 @@ import { writeManagedScript, type HooksConfig } from '../agent-hooks/installer-utils' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -114,25 +115,17 @@ function getManagedScript( // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. // Why: suppress parse errors so they neither leak nor trip outer set -e. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' unset ORCA_AGENT_HOOK_TRANSPORT', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' spool_hook_event', ' exit 0', 'fi', - // Why: post form fields because path-bearing payloads are unsafe in hand-built JSON. - // Why: pipe payload to curl stdin to keep large output off the command line. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', + // Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths. + ...buildPosixAgentHookPostCommand('claude').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), 'exit 0', '' ].join('\n') diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 8e892e69147..5694e4fe9bb 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -275,11 +275,15 @@ describe('Codex WSL runtime hook install', () => { it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', async () => { const script = _internals.getManagedScript('posix') expect(script).toContain('load_hook_endpoint()') + expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT') expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)') expect(script).toContain('post_codex_hook()') expect(script).toContain('is_wsl_runtime()') expect(script).toContain('WSL_DISTRO_NAME') expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)') + expect(script).toContain('-H "Content-Type: application/json"') + expect(script).toContain('-H "X-Orca-Agent-Hook-Meta-Encoding: base64"') + expect(script).toContain('--data-binary @-') expect(script).toContain('--data-urlencode "payload@-"') expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then') expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true') @@ -302,6 +306,7 @@ describe('Codex WSL runtime hook install', () => { 'set ORCA_AGENT_HOOK_TOKEN=fresh-token', 'set ORCA_AGENT_HOOK_ENV=development', 'set ORCA_AGENT_HOOK_VERSION=1', + 'set ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1', '' ].join('\r\n'), 'utf-8' @@ -333,6 +338,7 @@ describe('Codex WSL runtime hook install', () => { const posted = readFileSync(capturePath, 'utf-8') expect(posted).toContain('http://127.0.0.1:43210/hook/codex') expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token') + expect(posted).toContain('Content-Type: application/json') expect(posted).not.toContain('stale-token') } ) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index a25f14bcd89..0ea7837f154 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -19,6 +19,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' @@ -823,6 +824,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). 'load_hook_endpoint() {', ' endpoint_path="$1"', + ' unset ORCA_AGENT_HOOK_TRANSPORT', ' case "$endpoint_path" in', ' *.cmd)', // Why: Windows passes endpoint.cmd into WSL via WSLENV; parse only Orca's known assignments since cmd.exe `set` lines aren't shell syntax. @@ -834,6 +836,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;', ' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;', ' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_TRANSPORT="*) ORCA_AGENT_HOOK_TRANSPORT=${endpoint_line#*=} ;;', ' esac', ' done < "$endpoint_path"', ' ;;', @@ -853,20 +856,11 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' curl_bin="$1"', ' connect_timeout="${2:-0.5}"', ' max_time="${3:-1.5}"', - // Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe with quotes/newlines; post raw payload plus metadata as form fields instead. - // Why: pipe payload to curl's stdin (`payload@-`) not an inline arg, so tens-of-KB tool output stays off the command line (EDR false positives). - ' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', - ' --connect-timeout "$connect_timeout" --max-time "$max_time" \\', - ' --noproxy "127.0.0.1" \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-"', + // Why: keep full hook JSON off the command line and avoid URL-encoding paths/commands into IDS-friendly traversal signatures. + ...buildPosixAgentHookPostCommand('codex', { + curlCommand: '"$curl_bin"', + indent: ' ' + }).map((line) => ` ${line}`), '}', 'is_wsl_runtime() {', ' [ -n "$WSL_DISTRO_NAME" ] && return 0', diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts index 0cd0b183729..cabbde7b0ec 100644 --- a/src/main/ipc/pty/host-env/spawn-env-keys.ts +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -3,6 +3,7 @@ export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_AGENT_HOOK_ENV', 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_TRANSPORT', 'ORCA_AGENT_HOOK_ENDPOINT', // Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path. 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index f1275403001..133844dec78 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -73,6 +73,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_AGENT_HOOK_TOKEN: 'token', ORCA_AGENT_HOOK_ENV: 'dev', ORCA_AGENT_HOOK_VERSION: '1', + ORCA_AGENT_HOOK_TRANSPORT: 'raw-json-v1', ORCA_WSL_HOOK_INSTANCE: 'testinstance', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', @@ -95,6 +96,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u') + expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TRANSPORT/u') expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index a6fad65ca61..0fe855ba3b0 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -90,6 +90,7 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_AGENT_HOOK_TOKEN/u', 'ORCA_AGENT_HOOK_ENV/u', 'ORCA_AGENT_HOOK_VERSION/u', + 'ORCA_AGENT_HOOK_TRANSPORT/u', `ORCA_AGENT_HOOK_ENDPOINT/${endpointFlag}`, ...opencodeOverlayEntries, 'ORCA_WSL_HOOK_RELAY_VERSION/u', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0b25b3cef46..f83e5e777e4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2845,6 +2845,7 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_AGENT_HOOK_ENV', 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_TRANSPORT', 'ORCA_AGENT_HOOK_ENDPOINT' ] as const diff --git a/src/relay/agent-hook-endpoint-coordinates.ts b/src/relay/agent-hook-endpoint-coordinates.ts index 868fc120689..ac066f8afd9 100644 --- a/src/relay/agent-hook-endpoint-coordinates.ts +++ b/src/relay/agent-hook-endpoint-coordinates.ts @@ -4,7 +4,10 @@ import { basename, dirname, join } from 'node:path' import { homedir } from 'node:os' -import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../shared/agent-hook-types' // Why: relay's userData equivalent under $HOME so each user on a shared dev box gets their own 0o700 dir. const RELAY_HOOKS_DIR_NAME = '.orca-relay' @@ -49,7 +52,8 @@ export function buildRelayHookPtyEnv(coordinates: { ORCA_AGENT_HOOK_PORT: String(coordinates.port), ORCA_AGENT_HOOK_TOKEN: coordinates.token, ORCA_AGENT_HOOK_ENV: coordinates.env, - ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, + ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT } if (coordinates.endpointFileWritten) { env.ORCA_AGENT_HOOK_ENDPOINT = coordinates.endpointFilePath diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index e247706b5dc..c3a99b8a20c 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -8,6 +8,7 @@ import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' import * as agentHookListener from '../shared/agent-hook-listener/grok-result-discovery' +import { HOOK_REQUEST_MAX_BYTES } from '../shared/agent-hook-listener/request-body' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) @@ -42,7 +43,7 @@ describe('RelayAgentHookServer', () => { expect(endpointDir).not.toContain('\\\\.\\pipe') }) - it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => { + it('forwards a raw Claude JSON POST with base64 metadata headers', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) await server.start() @@ -52,16 +53,13 @@ describe('RelayAgentHookServer', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': token + 'X-Orca-Agent-Hook-Token': token, + 'X-Orca-Agent-Hook-Meta-Encoding': 'base64', + 'X-Orca-Agent-Hook-Meta': Buffer.from( + [PANE_KEY, 'tab-1', '', 'wt-1', 'remote', '1'].join('\x1f') + ).toString('base64') }, - body: JSON.stringify({ - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt-1', - env: 'remote', - version: '1', - payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' } - }) + body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'hi' }) }) expect(res.status).toBe(204) expect(forward).toHaveBeenCalledTimes(1) @@ -330,6 +328,27 @@ describe('RelayAgentHookServer', () => { } }) + it('rejects unknown hook paths before parsing the request body', async () => { + const forward = vi.fn() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/unknown`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ value: 'x'.repeat(HOOK_REQUEST_MAX_BYTES + 1) }) + }) + expect(res.status).toBe(404) + expect(forward).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + it('replays cached payloads on demand', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) @@ -492,6 +511,7 @@ describe('RelayAgentHookServer', () => { expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote') expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1') + expect(env.ORCA_AGENT_HOOK_TRANSPORT).toBe('raw-json-v1') expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy() } finally { server.stop() diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index ec980470dd4..59c1cbd8e00 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -2,7 +2,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import { randomUUID } from 'node:crypto' import { join } from 'node:path' -import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../shared/agent-hook-types' import { clearAllListenerCaches, clearPaneCacheState, @@ -15,6 +18,7 @@ import { } from '../shared/agent-hook-listener/endpoint-publication' import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' import { normalizeHookPayload } from '../shared/agent-hook-listener' +import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope' import { readRequestBody } from '../shared/agent-hook-listener/request-body' import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' @@ -174,7 +178,8 @@ export class RelayAgentHookServer { port: this.port, token: this.token, env: this.env, - version: ORCA_HOOK_PROTOCOL_VERSION + version: ORCA_HOOK_PROTOCOL_VERSION, + transport: ORCA_HOOK_RAW_JSON_TRANSPORT }) return this.endpointFileWritten } @@ -252,7 +257,6 @@ export class RelayAgentHookServer { req.destroy() }) try { - const body = await readRequestBody(req) const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname const source = resolveHookSource(pathname) if (!source) { @@ -260,16 +264,18 @@ export class RelayAgentHookServer { res.end() return } - const event = normalizeHookPayload(this.state, source, body, this.env, { + const body = await readRequestBody(req) + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + const event = normalizeHookPayload(this.state, source, hookBody, this.env, { deferCompactOwnershipToClient: true }) if (event) { // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. - const env = hookBodyEnv(body) - const version = hookBodyVersion(body) + const env = hookBodyEnv(hookBody) + const version = hookBodyVersion(hookBody) this.applyEvent(event, source, env, version) - this.retryScheduler.scheduleAssistantMessageRetry(source, body, event, env, version) - this.retryScheduler.scheduleCodexSubagentPoll(source, body, event, env, version) + this.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, event, env, version) + this.retryScheduler.scheduleCodexSubagentPoll(source, hookBody, event, env, version) } res.writeHead(204) res.end() diff --git a/src/shared/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts index e05ef801f7f..e23277e9f18 100644 --- a/src/shared/agent-hook-listener-relay-dependency.test.ts +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -122,6 +122,7 @@ describe('agent hook listener relay dependency boundary', () => { 'agent-hook-listener.ts', 'agent-hook-listener/endpoint-publication.ts', 'agent-hook-listener/grok-result-discovery.ts', + 'agent-hook-listener/hook-envelope.ts', 'agent-hook-listener/listener-limits.ts', 'agent-hook-listener/listener-state.ts', 'agent-hook-listener/providers/codex-state.ts', diff --git a/src/shared/agent-hook-listener-transport.test.ts b/src/shared/agent-hook-listener-transport.test.ts index 344be39eca3..f0a86986414 100644 --- a/src/shared/agent-hook-listener-transport.test.ts +++ b/src/shared/agent-hook-listener-transport.test.ts @@ -14,7 +14,10 @@ import { parseFormEncodedBody, readRequestBody } from './agent-hook-listener/request-body' +import { mergeAgentHookRequestHeaders } from './agent-hook-listener/hook-envelope' +import { createHookListenerState } from './agent-hook-listener/listener-state' import { resolveHookSource } from './agent-hook-listener/source-routing' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' type FakeIncomingMessage = EventEmitter & { @@ -38,6 +41,10 @@ function expectRequestParserListenersReleased(req: FakeIncomingMessage): void { } describe('shared agent-hook-listener', () => { + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + const b64 = (value: string): string => Buffer.from(value, 'utf8').toString('base64') + const packedMetadata = (...values: string[]): string => b64(values.join('\x1f')) + afterEach(() => { clearGrokSessionPathLookupCacheForTests() vi.unstubAllEnvs() @@ -60,6 +67,76 @@ describe('shared agent-hook-listener', () => { expectRequestParserListenersReleased(req) }) + it('normalizes a raw JSON hook body with metadata headers', async () => { + const req = createReadableRequest({ + 'content-type': 'application/json', + 'x-orca-pane-key': paneKey, + 'x-orca-worktree-id': 'repo::/tmp/work', + 'x-orca-agent-hook-env': 'production', + 'x-orca-agent-hook-version': '1' + }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from('{"hook_event_name":"UserPromptSubmit","prompt":"hello"}')) + req.emit('end') + + const merged = mergeAgentHookRequestHeaders(await body, req.headers) + expect( + normalizeHookPayload(createHookListenerState(), 'claude', merged, 'production') + ).toMatchObject({ + paneKey, + worktreeId: 'repo::/tmp/work', + payload: { state: 'working', prompt: 'hello' } + }) + }) + + it('decodes base64 metadata headers without corrupting path text', () => { + const worktreeId = 'repo::/tmp/中文 worktree' + const merged = mergeAgentHookRequestHeaders( + { hook_event_name: 'UserPromptSubmit', prompt: 'hello' }, + { + 'x-orca-agent-hook-meta-encoding': 'base64', + 'x-orca-agent-hook-meta': packedMetadata( + paneKey, + 'tab-1', + '', + worktreeId, + 'production', + '1' + ) + } + ) + + expect( + normalizeHookPayload(createHookListenerState(), 'claude', merged, 'production') + ).toMatchObject({ + paneKey, + tabId: 'tab-1', + worktreeId, + payload: { state: 'working', prompt: 'hello' } + }) + }) + + it('keeps raw envelope-like fields inside the agent payload', () => { + const rawBody = { + paneKey: 'payload-owned-pane', + payload: { hook_event_name: 'PayloadField' }, + hook_event_name: 'UserPromptSubmit', + prompt: 'hello' + } + const merged = mergeAgentHookRequestHeaders(rawBody, { + 'x-orca-pane-key': paneKey, + 'x-orca-tab-id': 'tab-1' + }) + + expect(merged).toMatchObject({ paneKey, tabId: 'tab-1', payload: rawBody }) + expect( + normalizeHookPayload(createHookListenerState(), 'claude', merged, 'production') + ).toMatchObject({ + paneKey, + payload: { state: 'working', prompt: 'hello' } + }) + }) + it('releases request parser listeners after rejecting an oversized body', async () => { const req = createReadableRequest({ 'content-type': 'application/json' }) const body = readRequestBody(req as unknown as IncomingMessage) @@ -168,13 +245,15 @@ describe('shared agent-hook-listener', () => { port: 12345, token: 'abcdef-0123', env: 'production', - version: '1' + version: '1', + transport: 'raw-json-v1' }) expect(ok).toBe(true) const text = readFileSync(finalPath, 'utf8') expect(text).toContain('ORCA_AGENT_HOOK_PORT=12345') expect(text).toContain('ORCA_AGENT_HOOK_TOKEN=abcdef-0123') expect(text).toContain('ORCA_AGENT_HOOK_VERSION=1') + expect(text).toContain('ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1') // POSIX 0o600 — owner read/write only. if (process.platform !== 'win32') { const mode = statSync(finalPath).mode & 0o777 diff --git a/src/shared/agent-hook-listener/endpoint-publication.ts b/src/shared/agent-hook-listener/endpoint-publication.ts index cb09f204a01..76964c00254 100644 --- a/src/shared/agent-hook-listener/endpoint-publication.ts +++ b/src/shared/agent-hook-listener/endpoint-publication.ts @@ -21,6 +21,7 @@ export type EndpointFileFields = { token: string env: string version: string + transport?: string } /** Atomically write the endpoint file at `endpointDir/`. @@ -39,6 +40,9 @@ export function writeEndpointFile( ['ORCA_AGENT_HOOK_ENV', fields.env], ['ORCA_AGENT_HOOK_VERSION', fields.version] ] + if (fields.transport) { + valuesToWrite.push(['ORCA_AGENT_HOOK_TRANSPORT', fields.transport]) + } for (const [key, value] of valuesToWrite) { if (!isShellSafeEndpointValue(value)) { console.error( diff --git a/src/shared/agent-hook-listener/hook-envelope.ts b/src/shared/agent-hook-listener/hook-envelope.ts index 5435f9fe912..57c29da338f 100644 --- a/src/shared/agent-hook-listener/hook-envelope.ts +++ b/src/shared/agent-hook-listener/hook-envelope.ts @@ -1,8 +1,91 @@ +import { Buffer } from 'node:buffer' +import type { IncomingHttpHeaders } from 'node:http' + import type { AgentHookSource } from '../agent-hook-relay' import { parsePaneKey } from '../stable-pane-id' import { MAX_PANE_KEY_LEN, warnOnHookEnvOrVersionMismatch } from './listener-limits' import type { HookListenerState } from './listener-state' import { parseAgentHookJson } from './request-body' + +function readHookHeader(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name] + return Array.isArray(value) ? value[0] : value +} + +function decodeBase64HookHeader(value: string): string | undefined { + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 === 1) { + return undefined + } + const decoded = Buffer.from(value, 'base64').toString('utf8') + const normalizedInput = value.replace(/=+$/, '') + const normalizedRoundTrip = Buffer.from(decoded, 'utf8').toString('base64').replace(/=+$/, '') + return normalizedRoundTrip === normalizedInput ? decoded : undefined +} + +function readHookMetadataHeader( + headers: IncomingHttpHeaders, + name: string, + encoding: 'base64' | undefined +): string | undefined { + const value = readHookHeader(headers, name) + if (value === undefined) { + return undefined + } + return encoding === 'base64' ? decodeBase64HookHeader(value) : value +} + +type HookMetadata = { + paneKey: string + tabId?: string + launchToken?: string + worktreeId?: string + env?: string + version?: string +} + +function readPackedHookMetadata( + headers: IncomingHttpHeaders, + encoding: 'base64' | undefined +): HookMetadata | null { + const encoded = readHookHeader(headers, 'x-orca-agent-hook-meta') + if (encoded === undefined || encoding !== 'base64') { + return null + } + const decoded = decodeBase64HookHeader(encoded) + if (decoded === undefined) { + return null + } + // POSIX command substitution strips NUL bytes, so use the shell-safe unit separator. + const fields = decoded.split('\x1f') + if (fields.length !== 6 || !fields[0]) { + return null + } + const [paneKey, tabId, launchToken, worktreeId, env, version] = fields + return { paneKey, tabId, launchToken, worktreeId, env, version } +} + +/** Rebuilds the canonical envelope for POSIX hooks that carry raw JSON bodies. */ +export function mergeAgentHookRequestHeaders(body: unknown, headers: IncomingHttpHeaders): unknown { + const metadataEncoding = + readHookHeader(headers, 'x-orca-agent-hook-meta-encoding')?.trim().toLowerCase() === 'base64' + ? 'base64' + : undefined + const metadata = readPackedHookMetadata(headers, metadataEncoding) ?? { + paneKey: readHookMetadataHeader(headers, 'x-orca-pane-key', metadataEncoding) ?? '', + tabId: readHookMetadataHeader(headers, 'x-orca-tab-id', metadataEncoding), + launchToken: readHookMetadataHeader(headers, 'x-orca-launch-token', metadataEncoding), + worktreeId: readHookMetadataHeader(headers, 'x-orca-worktree-id', metadataEncoding), + env: readHookMetadataHeader(headers, 'x-orca-agent-hook-env', metadataEncoding), + version: readHookMetadataHeader(headers, 'x-orca-agent-hook-version', metadataEncoding) + } + if (!metadata.paneKey) { + return body + } + return { + ...metadata, + payload: body + } +} function readEnvelopeString(record: Record, key: string): string | undefined { const value = record[key] if (typeof value !== 'string') { diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 5319dffceaf..248638c5079 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -49,3 +49,6 @@ export type AgentHookInstallStatus = { // rewritten on every install() call so there is no durable on-disk v1 script // to inherit. Reserve the next bump for a real wire change. export const ORCA_HOOK_PROTOCOL_VERSION = '1' as const + +// Why: absence means the listener predates raw-JSON metadata headers, so managed scripts must keep using form posts. +export const ORCA_HOOK_RAW_JSON_TRANSPORT = 'raw-json-v1' as const From 9635e6822f491ed66847e01f155edf7d7052da61 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:35:03 -0700 Subject: [PATCH 027/118] Normalize project catalog rows defensively (#16826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip: normalize untrusted project catalog rows at load boundaries * fix(catalog): make ProjectHostSetup field types true at the ingest boundary Crash 3bcc5be3: a setup row whose repoId arrived null reached Settings' projectByRepoId memo and threw on .trim(). The type said `string`; persisted JSON and remote hosts on other versions can disagree. Normalize project/setup rows where untrusted data enters typed code — the persisted-state load (marking dirty, which is the migration), profile transfer reads, the repo-derived projection, and the renderer's IPC/RPC ingest and adoption steps — instead of re-guarding each consumer. Also covers `setup.path`, whose identical crash is on the sidebar render path. Coercion only: never drops a row, never adds or removes an optional key, and returns input references when a row already conforms, so selector and useMemo identity is unchanged. * refactor(catalog): drop the `as` casts the normalizer introduced A change whose thesis is "stop the declared types from lying" should not use `as` to paper over types. The four source casts all came from the row normalizers returning `readonly` arrays into mutably-owned fields. Take and return mutable arrays instead, and copy at the one caller that holds a readonly projection — where identity is not load-bearing, unlike the persistence path, whose dirty check compares it. Tests built deliberately malformed rows by casting a literal. Build a valid row and `Reflect.set` the bad value onto it, which says outright that the fixture violates its type; parse the non-array case from JSON, which is how it actually arrives. Fixtures that were merely incomplete needed no cast at all — `Repo` requires only the five fields they already had. Also narrow normalizeLoadedProjectCatalog to the two fields it reads. * refactor(profiles): validate untrusted profile JSON instead of asserting it Removes the last introduced cast and the unsound pre-existing ones in the file this change already touches. The test cast is gone because narrowing normalizeLoadedProjectCatalog to the two fields it reads made `{}` assignable on its own. arrayOrEmpty and recordOrEmpty checked the shape and then asserted the element type, which is the same "declared type is a promise the data does not keep" problem this change exists to fix. Array.isArray already narrows on its own, and a generic isRecord narrows the value part, so both assertions delete outright. JSON.parse returns any, so annotating the binding beats asserting its result. The two remaining `as const` in project-host-setup-actions are untouched and deliberate: a literal assertion narrows a type rather than overriding it. * Make project catalog normalizers handle null rows defensively Instead of crashing when corrupt or null catalog rows are encountered, the normalizers now gracefully repair them with default values. Refactored helper functions for clarity and added type predicates to improve type narrowing. --- .../profile-project-state-file.ts | 31 +-- .../normalize-loaded-profile-state.ts | 7 +- .../normalize-loaded-project-catalog.test.ts | 67 ++++++ .../normalize-loaded-state-collections.ts | 22 ++ .../project-catalog-null-field-ingest.test.ts | 167 ++++++++++++++ .../store/projects/project-host-routing.ts | 31 ++- .../projects/project-host-setup-actions.ts | 28 ++- .../src/store/projects/project-update.ts | 5 +- .../project-catalog-row-normalization.test.ts | 207 ++++++++++++++++++ .../project-catalog-row-normalization.ts | 123 +++++++++++ src/shared/project-host-setup-projection.ts | 10 +- 11 files changed, 663 insertions(+), 35 deletions(-) create mode 100644 src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts create mode 100644 src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts create mode 100644 src/shared/project-catalog-row-normalization.test.ts create mode 100644 src/shared/project-catalog-row-normalization.ts diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index 5c623f19bad..d4f291a09e4 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -3,8 +3,11 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from ' import { homedir } from 'node:os' import { dirname } from 'node:path' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' -import type { ExecutionHostId } from '../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import { + normalizeProjectHostSetupRows, + normalizeProjectRows +} from '../../shared/project-catalog-row-normalization' import { carryProjectStateThroughIdentityChange } from '../../shared/project-identity-succession' import type { PersistedState } from '../../shared/persisted-state-types' import type { Project, ProjectHostSetup } from '../../shared/project-types' @@ -16,16 +19,16 @@ import { getOrcaProfileDataFile } from './profile-index-store' export type TransferProfileState = PersistedState -function isRecord(value: unknown): value is Record { +function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function arrayOrEmpty(value: unknown): T[] { - return Array.isArray(value) ? (value as T[]) : [] + return Array.isArray(value) ? value : [] } function recordOrEmpty(value: unknown): Record { - return isRecord(value) ? (value as Record) : {} + return isRecord(value) ? value : {} } export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { @@ -34,13 +37,17 @@ export function readProfileState(profileId: string, userDataPath: string): Trans if (!existsSync(dataFile)) { return structuredClone(defaults) } - const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as Partial + const parsed: Partial = JSON.parse(readFileSync(dataFile, 'utf-8')) return rebuildRepoBackedProjectState({ ...defaults, ...parsed, repos: arrayOrEmpty(parsed.repos), - projects: arrayOrEmpty(parsed.projects), - projectHostSetups: arrayOrEmpty(parsed.projectHostSetups), + // Why normalize: another profile's file is untrusted JSON, and a null repoId/path here would be + // carried straight into the importing app's state. + projects: normalizeProjectRows(arrayOrEmpty(parsed.projects)), + projectHostSetups: normalizeProjectHostSetupRows( + arrayOrEmpty(parsed.projectHostSetups) + ), projectGroups: arrayOrEmpty(parsed.projectGroups), folderWorkspaces: arrayOrEmpty(parsed.folderWorkspaces), sparsePresetsByRepo: recordOrEmpty(parsed.sparsePresetsByRepo), @@ -59,17 +66,15 @@ export function readProfileState(profileId: string, userDataPath: string): Trans ui: isRecord(parsed.ui) ? { ...defaults.ui, ...parsed.ui } : defaults.ui, githubCache: isRecord(parsed.githubCache) ? { - pr: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).pr), - issue: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).issue) + pr: recordOrEmpty(parsed.githubCache.pr), + issue: recordOrEmpty(parsed.githubCache.issue) } : defaults.githubCache, workspaceSession: isRecord(parsed.workspaceSession) ? { ...getDefaultWorkspaceSession(), ...parsed.workspaceSession } : defaults.workspaceSession, - workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId) - ? (parsed.workspaceSessionsByHostId as Partial< - Record - >) + workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId) + ? parsed.workspaceSessionsByHostId : {}, sshTargets: arrayOrEmpty(parsed.sshTargets), sshRemotePtyLeases: arrayOrEmpty(parsed.sshRemotePtyLeases), diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 464a1eaa9b8..2286e40d54b 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -21,7 +21,8 @@ import { normalizeLoadedUiState } from './normalize-loaded-ui-state' import { normalizeLoadedAutomationRuns, normalizeLoadedHostSessions, - normalizeLoadedLocalSession + normalizeLoadedLocalSession, + normalizeLoadedProjectCatalog } from './normalize-loaded-state-collections' import { normalizeRetiredNameRegistryMap } from './retired-name-registry-normalization' @@ -33,6 +34,7 @@ export function normalizeLoadedProfileState( ): PersistedState { const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile + const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave) return { ...defaults, @@ -42,6 +44,9 @@ export function normalizeLoadedProfileState( ), projectGroups: normalizedProjectGroups, repos: migratedExternalVisibility.repos, + // Why: persisted catalog rows are untrusted JSON; consumers call string methods on fields the type says are strings. + projects: projectCatalog.projects, + projectHostSetups: projectCatalog.projectHostSetups, folderWorkspaces: normalizeFolderWorkspaces(parsed.folderWorkspaces, normalizedProjectGroups), folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( parsed.folderWorkspaceDiffComments diff --git a/src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts b/src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts new file mode 100644 index 00000000000..8a535b41919 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Project, ProjectHostSetup } from '../../../shared/project-types' +import { normalizeLoadedProjectCatalog } from './normalize-loaded-state-collections' + +// Why Reflect.set and not a cast: these rows deliberately violate their declared types, which is +// exactly what a stale on-disk catalog does. +function corrupt(row: T, overrides: Record): T { + for (const [key, value] of Object.entries(overrides)) { + Reflect.set(row, key, value) + } + return row +} + +function makeParsed( + setups: ProjectHostSetup[], + projects: Project[] = [] +): Partial> { + return { projects, projectHostSetups: setups } +} + +function makeBadSetup(): ProjectHostSetup { + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + return corrupt(setup, { repoId: null, path: null }) +} + +describe('normalizeLoadedProjectCatalog', () => { + it('repairs stored rows whose field types do not match the declared ones', () => { + const result = normalizeLoadedProjectCatalog(makeParsed([makeBadSetup()]), vi.fn()) + expect(result.projectHostSetups[0]?.repoId).toBe('') + expect(result.projectHostSetups[0]?.path).toBe('') + }) + + // Why: without a save the bad rows stay on disk, get repaired again every launch, and this + // host keeps publishing them to paired clients. Marking dirty is the migration. + it('marks the profile dirty so the repair is persisted', () => { + const markNeedsSave = vi.fn() + normalizeLoadedProjectCatalog(makeParsed([makeBadSetup()]), markNeedsSave) + expect(markNeedsSave).toHaveBeenCalled() + }) + + it('leaves a conforming catalog untouched and does not schedule a save', () => { + const markNeedsSave = vi.fn() + const setups = [{ ...makeBadSetup(), repoId: 'repo-1', path: '/repo' }] + const parsed = makeParsed(setups) + const result = normalizeLoadedProjectCatalog(parsed, markNeedsSave) + expect(result.projectHostSetups).toBe(setups) + expect(markNeedsSave).not.toHaveBeenCalled() + }) + + it('tolerates missing or non-array collections', () => { + const result = normalizeLoadedProjectCatalog({}, vi.fn()) + expect(result.projects).toEqual([]) + expect(result.projectHostSetups).toEqual([]) + }) +}) diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts index 978515a5752..2b133d486b7 100644 --- a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -1,4 +1,8 @@ import type { PersistedState } from '../../../shared/persisted-state-types' +import { + normalizeProjectHostSetupRows, + normalizeProjectRows +} from '../../../shared/project-catalog-row-normalization' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' import { @@ -64,3 +68,21 @@ export function normalizeLoadedAutomationRuns( } return runs } + +/** + * Repairs project/setup rows whose stored field types do not match the declared ones — a null + * `repoId` or `path` written by an older build reaches every consumer that calls `.trim()` on it. + * Marking dirty is the migration: without a save the bad rows stay on disk and are repaired again + * every launch, and this host keeps publishing them to paired clients over the wire. + */ +export function normalizeLoadedProjectCatalog( + parsed: Partial>, + markNeedsSave: () => void +): Pick { + const projects = normalizeProjectRows(parsed.projects ?? []) + const projectHostSetups = normalizeProjectHostSetupRows(parsed.projectHostSetups ?? []) + if (projects !== parsed.projects || projectHostSetups !== parsed.projectHostSetups) { + markNeedsSave() + } + return { projects, projectHostSetups } +} diff --git a/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts new file mode 100644 index 00000000000..1d57bde1b87 --- /dev/null +++ b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { getProjectHostSetupProjectionFromState } from '../project-host-setup-selector' +import { fetchProjectHostSetupCompatibility, setupWithFetchedOwner } from './project-host-routing' + +// Crash 3bcc5be3 (v1.4.188, Linux, page.settings boundary): a setup row whose repoId arrived +// null reached Settings' projectByRepoId useMemo and threw "Cannot read properties of null +// (reading 'trim')". Normalizing at ingest is what must make that unreachable — by the time a +// row is in the store, its declared field types hold. +const repos = [ + { id: 'repo-1', path: '/Users/alice/orca', displayName: 'orca', badgeColor: '#000', addedAt: 1 } +] satisfies Repo[] + +const projects = [ + { + id: 'repo:repo-1', + displayName: 'orca', + badgeColor: '#000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } +] satisfies Project[] + +// Mirrors the Settings.tsx projectByRepoId useMemo that crashed. +function buildProjectByRepoIdLikeSettings( + setups: readonly ProjectHostSetup[], + projectList: readonly Project[] +): Map { + const projectById = new Map(projectList.map((project) => [project.id, project])) + const byRepoId = new Map() + for (const setup of setups) { + const project = projectById.get(setup.projectId) + if (project && setup.repoId.trim()) { + byRepoId.set(setup.repoId, project) + } + } + return byRepoId +} + +// Mirrors project-grouping.ts's checkout identity, which runs on the sidebar render path. +function checkoutIdentityLikeSidebar(setup: ProjectHostSetup): string { + return setup.path.trim() || setup.repoId || setup.id +} + +// Why Reflect.set and not a cast: the second row deliberately violates its declared types, which +// is exactly what an older host publishes over the wire. +function badSetups(): ProjectHostSetup[] { + const base = (id: string, displayName: string): ProjectHostSetup => ({ + id, + projectId: 'repo:repo-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + }) + const corrupted = base('repo:repo-1::local::2', 'orca-2') + Reflect.set(corrupted, 'repoId', null) + Reflect.set(corrupted, 'path', null) + return [base('repo:repo-1::local', 'orca'), corrupted] +} + +function stubProjectsApi(setups: ProjectHostSetup[], projectRows: Project[] = projects): void { + vi.stubGlobal('window', { + api: { + projects: { + list: vi.fn().mockResolvedValue(projectRows), + listHostSetups: vi.fn().mockResolvedValue(setups) + } + } + }) +} + +describe('project catalog ingest with non-string row fields', () => { + it('coerces a null repoId and path on the local IPC boundary', async () => { + stubProjectsApi(badSetups()) + const projection = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const row = projection.setups.find((setup) => setup.id === 'repo:repo-1::local::2') + expect(row?.repoId).toBe('') + expect(row?.path).toBe('') + }) + + // Why: a remote host on a different Orca version is a first-class source of these rows, and + // decoders hand them over verbatim — the client cannot assume the host already repaired them. + it('coerces on the remote adoption boundary too', () => { + const adopted = setupWithFetchedOwner(badSetups()[1]!, { + kind: 'environment', + environmentId: 'env-1' + }) + expect(adopted.repoId).toBe('') + expect(adopted.path).toBe('') + expect(adopted.hostId).toBe('runtime:env-1') + }) + + it('lets the Settings projectByRepoId memo run instead of throwing on .trim()', async () => { + stubProjectsApi(badSetups()) + const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const projection = getProjectHostSetupProjectionFromState({ + repos, + projects: [...ingested.projects], + projectHostSetups: [...ingested.setups] + }) + expect(() => + buildProjectByRepoIdLikeSettings(projection.setups, projection.projects) + ).not.toThrow() + // Why: an empty repoId is not an openable repo row, so it must not be indexed. + const byRepoId = buildProjectByRepoIdLikeSettings(projection.setups, projection.projects) + expect(byRepoId.has('')).toBe(false) + expect([...byRepoId.keys()]).toEqual(['repo-1']) + }) + + // Why: a null path takes down the whole main window rather than one error boundary, because + // the sidebar grouping index is built on every render. + it('lets the sidebar checkout identity run instead of throwing on a null path', async () => { + stubProjectsApi(badSetups()) + const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + expect(() => ingested.setups.map(checkoutIdentityLikeSidebar)).not.toThrow() + }) + + // Why: a coerced field must change one value, never which rows exist. + it('does not add or drop rows because a field was repaired', async () => { + stubProjectsApi(badSetups()) + const repaired = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const clean = badSetups() + ;(clean[1] as { repoId: string; path: string }).repoId = '' + ;(clean[1] as { repoId: string; path: string }).path = '' + stubProjectsApi(clean) + const untouched = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + expect(repaired.setups.map((setup) => setup.id)).toEqual( + untouched.setups.map((setup) => setup.id) + ) + expect(repaired.projects.map((project) => project.id)).toEqual( + untouched.projects.map((project) => project.id) + ) + }) + + // Why: this is a zustand selector compared with Object.is, so instability is a re-render storm. + it('keeps the projection reference-stable per (repos, projects, setups) input', async () => { + stubProjectsApi(badSetups()) + const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const args = { + repos, + projects: [...ingested.projects], + projectHostSetups: [...ingested.setups] + } + expect(getProjectHostSetupProjectionFromState(args)).toBe( + getProjectHostSetupProjectionFromState(args) + ) + }) + + // Why: nothing else in the ingest path allocates, so a clean catalog must hand back the very + // rows it was given — TerminalPane and TabBarQuickCommandsButton use them as useMemo deps. + it('preserves row identity when the catalog is already clean', async () => { + const clean = badSetups() + ;(clean[1] as { repoId: string; path: string }).repoId = '' + ;(clean[1] as { repoId: string; path: string }).path = '' + stubProjectsApi(clean) + const projection = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + expect(projection.setups[0]).toBe(clean[0]) + expect(projection.setups[1]).toBe(clean[1]) + }) +}) diff --git a/src/renderer/src/store/projects/project-host-routing.ts b/src/renderer/src/store/projects/project-host-routing.ts index 43e32524ad3..1a4a45ea03a 100644 --- a/src/renderer/src/store/projects/project-host-routing.ts +++ b/src/renderer/src/store/projects/project-host-routing.ts @@ -9,6 +9,11 @@ import { projectHostSetupProjectionFromRepos, type ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' +import { + normalizeProjectHostSetupRow, + normalizeProjectHostSetupRows, + normalizeProjectRows +} from '../../../../shared/project-catalog-row-normalization' import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY @@ -51,13 +56,16 @@ export function setupWithFetchedOwner( setup: ProjectHostSetup, target: RuntimeClientTarget ): ProjectHostSetup { + // Why here: every setup row crossing IPC or RPC into the renderer passes through this + // adoption step, so it is where the declared field types stop being aspirational. + const adopted = normalizeProjectHostSetupRow(setup) const hostId = getRuntimeTargetHostId(target) if (target.kind !== 'environment') { - return setup + return adopted } - const executionHostId = setup.executionHostId ?? setup.hostId + const executionHostId = adopted.executionHostId ?? adopted.hostId return { - ...setup, + ...adopted, hostId, executionHostId: executionHostId === LOCAL_EXECUTION_HOST_ID ? hostId : executionHostId, runtimeOwnerEnvironmentId: target.environmentId, @@ -66,6 +74,15 @@ export function setupWithFetchedOwner( } } +function normalizeProjectCatalogProjection( + projection: ProjectHostSetupProjection +): ProjectHostSetupProjection { + return { + projects: normalizeProjectRows([...projection.projects]), + setups: normalizeProjectHostSetupRows([...projection.setups]) + } +} + async function assertProjectHostSetupRuntimeCapability(target: RuntimeClientTarget): Promise { if (target.kind !== 'environment') { return @@ -95,10 +112,10 @@ export async function fetchProjectHostSetupCompatibility( if (!projectsApi?.list || !projectsApi.listHostSetups) { throw new Error('projects_api_unavailable') } - return { + return normalizeProjectCatalogProjection({ projects: await projectsApi.list(), setups: await projectsApi.listHostSetups() - } + }) } await assertProjectHostSetupRuntimeCapability(target) const [projectResponse, setupResponse] = await Promise.all([ @@ -110,7 +127,9 @@ export async function fetchProjectHostSetupCompatibility( }) ]) return { - projects: projectResponse.projects, + // Why projects too: the same wire response carries them, and a remote host on another + // Orca version can publish a row whose declared field types do not hold. + projects: normalizeProjectRows([...projectResponse.projects]), setups: setupResponse.setups.map((setup) => setupWithFetchedOwner(setup, target)) } } catch { diff --git a/src/renderer/src/store/projects/project-host-setup-actions.ts b/src/renderer/src/store/projects/project-host-setup-actions.ts index 97e74dbc9b8..dbc106a6afa 100644 --- a/src/renderer/src/store/projects/project-host-setup-actions.ts +++ b/src/renderer/src/store/projects/project-host-setup-actions.ts @@ -16,6 +16,7 @@ import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared import type { RepoSlice } from '../repos/repo-state' import { ERROR_TOAST_DURATION } from '../repos/repo-state' import { repoWithFetchedOwner } from '../repos/owner-routing' +import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' import { assertProjectHostSetupMutationRuntimeCapabilities, getProjectSetupRuntimeTarget, @@ -57,6 +58,7 @@ export function createProjectHostSetupActions( const repo = repoWithFetchedOwner(result.repo, target) const repoHostId = getRepoExecutionHostId(repo) const setup = setupWithFetchedOwner(result.setup, target) + const project = normalizeProjectRow(result.project) set((s) => { const nextRepos = s.repos.some((entry) => repoMatchesHostIdentity(entry, repo.id, repoHostId) @@ -65,9 +67,9 @@ export function createProjectHostSetupActions( repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry ) : [...s.repos, repo] - const nextProjects = s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project] + const nextProjects = s.projects.some((entry) => entry.id === project.id) + ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.projects, project] const nextSetups = s.projectHostSetups.some((entry) => entry.id === setup.id) ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) : [...s.projectHostSetups, setup] @@ -80,7 +82,7 @@ export function createProjectHostSetupActions( toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), { description: repo.displayName }) - return { ...result, repo, setup } + return { ...result, project, repo, setup } } catch (err) { console.error('Failed to set up project on host:', err) const message = err instanceof Error ? err.message : String(err) @@ -108,15 +110,16 @@ export function createProjectHostSetupActions( ) ).result const setup = setupWithFetchedOwner(result.setup, target) + const project = normalizeProjectRow(result.project) set((s) => ({ - projects: s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project], + projects: s.projects.some((entry) => entry.id === project.id) + ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.projects, project], projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) : [...s.projectHostSetups, setup] })) - return { project: result.project, setup } + return { project, setup } } catch (err) { console.error('Failed to create project host setup:', err) const message = err instanceof Error ? err.message : String(err) @@ -147,6 +150,7 @@ export function createProjectHostSetupActions( ) ).result const setup = setupWithFetchedOwner(result.setup, target) + const project = normalizeProjectRow(result.project) const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => ({ @@ -157,14 +161,14 @@ export function createProjectHostSetupActions( ) : [...s.repos, repo] : s.repos, - projects: s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project], + projects: s.projects.some((entry) => entry.id === project.id) + ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.projects, project], projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) : [...s.projectHostSetups, setup] })) - return { ...result, repo, setup } + return { ...result, project, repo, setup } } catch (err) { console.error('Failed to update project host setup:', err) const message = err instanceof Error ? err.message : String(err) diff --git a/src/renderer/src/store/projects/project-update.ts b/src/renderer/src/store/projects/project-update.ts index 7b3ece6fd62..75c65f24ee3 100644 --- a/src/renderer/src/store/projects/project-update.ts +++ b/src/renderer/src/store/projects/project-update.ts @@ -6,6 +6,7 @@ import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill import type { RepoSlice } from '../repos/repo-state' import { getProjectUpdateRuntimeTarget } from './project-host-routing' import { mergeUpdatedProjectCompatibilityProject } from './project-compatibility-core' +import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' export function createProjectUpdateActions( set: Parameters>[0], @@ -29,11 +30,13 @@ export function createProjectUpdateActions( if (!updatedProject) { return false } + // Why: the merge spreads updatedProject.sourceRepoIds, which throws if the host sent a non-array. + const normalizedProject = normalizeProjectRow(updatedProject) const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates set((state) => ({ projects: state.projects.map((project) => project.id === projectId - ? mergeUpdatedProjectCompatibilityProject(project, updatedProject, updates) + ? mergeUpdatedProjectCompatibilityProject(project, normalizedProject, updates) : project ), folderWorkspacePathStatuses: {} diff --git a/src/shared/project-catalog-row-normalization.test.ts b/src/shared/project-catalog-row-normalization.test.ts new file mode 100644 index 00000000000..acab501f41e --- /dev/null +++ b/src/shared/project-catalog-row-normalization.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import type { Project, ProjectHostSetup } from './project-types' +import { + normalizeProjectHostSetupRow, + normalizeProjectHostSetupRows, + normalizeProjectRow, + normalizeProjectRows +} from './project-catalog-row-normalization' + +// Why Reflect.set and not a cast: these fixtures deliberately violate the declared types, which is +// the whole point — writing the bad value onto a valid row says that outright. +function corrupt(row: T, overrides: Record): T { + for (const [key, value] of Object.entries(overrides)) { + Reflect.set(row, key, value) + } + return row +} + +function makeSetup(overrides: Record = {}): ProjectHostSetup { + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 2 + } + return corrupt(setup, overrides) +} + +function makeProject(overrides: Record = {}): Project { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 2 + } + return corrupt(project, overrides) +} + +describe('normalizeProjectHostSetupRow', () => { + it('returns the input reference when every declared type already holds', () => { + const setup = makeSetup() + expect(normalizeProjectHostSetupRow(setup)).toBe(setup) + }) + + it.each([ + ['null', null], + ['undefined', undefined], + ['a number', 42], + ['an object', {}] + ])('coerces a %s repoId to an empty string', (_label, repoId) => { + expect(normalizeProjectHostSetupRow(makeSetup({ repoId })).repoId).toBe('') + }) + + // Why path: `project-grouping.ts` calls `.trim()` and `parseWslUncPath()` on it during + // sidebar render, so the same bad row takes down the main window, not one error boundary. + it.each([ + ['null', null], + ['a number', 7] + ])('coerces a %s path to an empty string', (_label, path) => { + expect(normalizeProjectHostSetupRow(makeSetup({ path })).path).toBe('') + }) + + it('coerces every other required string field', () => { + const normalized = normalizeProjectHostSetupRow( + makeSetup({ id: null, projectId: null, displayName: null }) + ) + expect(normalized.id).toBe('') + expect(normalized.projectId).toBe('') + expect(normalized.displayName).toBe('') + }) + + it('falls back to the local host id for a non-string hostId', () => { + expect(normalizeProjectHostSetupRow(makeSetup({ hostId: null })).hostId).toBe('local') + }) + + // Why 0: the catalog merge already reads 0 as "unknown", so it degrades instead of + // restamping a fake timestamp that would win min()/max() against a real one. + it.each([ + ['null', null], + ['NaN', Number.NaN], + ['a string', '5'] + ])('coerces a %s timestamp to 0', (_label, value) => { + const normalized = normalizeProjectHostSetupRow( + makeSetup({ createdAt: value, updatedAt: value }) + ) + expect(normalized.createdAt).toBe(0) + expect(normalized.updatedAt).toBe(0) + }) + + // Why: these are only ever compared, never string-method'd — inventing a fallback would + // silently change what a corrupt row claims about itself. + it('leaves the setupState and setupMethod unions untouched', () => { + const normalized = normalizeProjectHostSetupRow( + makeSetup({ repoId: null, setupState: 'bogus', setupMethod: null }) + ) + expect(normalized.setupState).toBe('bogus') + expect(normalized.setupMethod).toBeNull() + }) + + // Why: `mergeProjectCompatibilityProject` and the setup projection branch on `in` / + // `!== undefined`, so materialising an absent optional key changes merge behaviour. + it('never adds or removes an optional key', () => { + const withoutOptionals = normalizeProjectHostSetupRow(makeSetup({ repoId: null })) + expect('connectionId' in withoutOptionals).toBe(false) + expect('worktreeBasePath' in withoutOptionals).toBe(false) + const withOptionals = normalizeProjectHostSetupRow( + makeSetup({ repoId: null, connectionId: null, worktreeBasePath: '/base' }) + ) + expect('connectionId' in withOptionals).toBe(true) + expect(withOptionals.worktreeBasePath).toBe('/base') + }) +}) + +describe('normalizeProjectRow', () => { + it('returns the input reference when every declared type already holds', () => { + const project = makeProject() + expect(normalizeProjectRow(project)).toBe(project) + }) + + // Why: the compatibility merge spreads `sourceRepoIds`, which throws on a non-array. + it.each([ + ['null', null, []], + ['a string', 'repo-1', []], + ['an array with holes', ['repo-1', null, 3], ['repo-1']] + ])('repairs a %s sourceRepoIds', (_label, sourceRepoIds, expected) => { + expect(normalizeProjectRow(makeProject({ sourceRepoIds })).sourceRepoIds).toEqual(expected) + }) + + it('coerces the required string fields', () => { + const normalized = normalizeProjectRow( + makeProject({ id: null, displayName: null, badgeColor: 42 }) + ) + expect(normalized.id).toBe('') + expect(normalized.displayName).toBe('') + expect(normalized.badgeColor).toBe('') + }) + + // Why: `'localWindowsRuntimePreference' in overlay` decides whether a preference clear wins. + it('never materialises an absent localWindowsRuntimePreference', () => { + const normalized = normalizeProjectRow(makeProject({ id: null })) + expect('localWindowsRuntimePreference' in normalized).toBe(false) + }) +}) + +describe('row array normalization', () => { + it('repairs null rows without throwing', () => { + const setups = [null] as unknown as ProjectHostSetup[] + const projects = [null] as unknown as Project[] + expect(normalizeProjectHostSetupRows(setups)[0]).toMatchObject({ + id: '', + repoId: '', + path: '' + }) + expect(normalizeProjectRows(projects)[0]).toMatchObject({ + id: '', + sourceRepoIds: [] + }) + }) + + it('returns the input array when nothing needed repair', () => { + const setups = [makeSetup(), makeSetup({ id: 'setup-2' })] + expect(normalizeProjectHostSetupRows(setups)).toBe(setups) + const projects = [makeProject()] + expect(normalizeProjectRows(projects)).toBe(projects) + }) + + // Why: TerminalPane and TabBarQuickCommandsButton use these arrays as useMemo deps, so an + // untouched row must keep its object identity across normalization. + it('reuses untouched rows and only reallocates the repaired one', () => { + const setups = [ + makeSetup(), + makeSetup({ id: 'setup-2', repoId: null }), + makeSetup({ id: 's3' }) + ] + const normalized = normalizeProjectHostSetupRows(setups) + expect(normalized).not.toBe(setups) + expect(normalized[0]).toBe(setups[0]) + expect(normalized[1]).not.toBe(setups[1]) + expect(normalized[2]).toBe(setups[2]) + }) + + // Why: coercing a field must change one value, never which rows exist — setting a "changed" + // flag on the old selector normalizer unioned in repo-derived rows and invented phantom rows. + it('preserves row count and order', () => { + const setups = [makeSetup({ repoId: null }), makeSetup({ id: 'setup-2' })] + expect(normalizeProjectHostSetupRows(setups).map((setup) => setup.id)).toEqual([ + 'setup-1', + 'setup-2' + ]) + }) + + it('treats a non-array as empty', () => { + // Why JSON.parse: a persisted catalog really can hold a non-array here, and parsing is how it + // arrives — the parameter type cannot express it. + const notAnArray: never[] = JSON.parse('null') + expect(normalizeProjectHostSetupRows(notAnArray)).toEqual([]) + expect(normalizeProjectRows(notAnArray)).toEqual([]) + }) +}) diff --git a/src/shared/project-catalog-row-normalization.ts b/src/shared/project-catalog-row-normalization.ts new file mode 100644 index 00000000000..437545e639a --- /dev/null +++ b/src/shared/project-catalog-row-normalization.ts @@ -0,0 +1,123 @@ +import { LOCAL_EXECUTION_HOST_ID } from './execution-host' +import type { Project, ProjectHostSetup } from './project-types' + +/** + * Repairs untrusted `Project` / `ProjectHostSetup` rows so their declared field types are true. + * + * These rows reach typed code from persisted JSON and from remote Orca hosts running a different + * version, where a field the type promises is a `string` can arrive `null`, missing, or another + * type entirely — while consumers call `.trim()` on it unconditionally (crash 3bcc5be3). Call + * these at every boundary such a row enters the app, so nothing downstream has to re-guard. + * + * Coercion only. A normalizer never drops a row, never adds or removes an optional key, and + * returns its input reference untouched when it already conforms — callers keep row and array + * identity, which selectors and `useMemo` deps depend on. + * + * The string-literal unions (`setupState`, `setupMethod`) are deliberately left alone: they are + * only ever compared, so an unknown value degrades instead of crashing, and inventing a fallback + * would silently change what a corrupt row claims about itself. + */ + +function isString(value: unknown): value is string { + return typeof value === 'string' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Why 0 and not now(): the catalog merge already reads 0 as "timestamp unknown", not as the epoch. +function isTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function stringValue(value: unknown, fallback = ''): string { + return isString(value) ? value : fallback +} + +function timestampValue(value: unknown): number { + return isTimestamp(value) ? value : 0 +} + +export function normalizeProjectHostSetupRow(setup: ProjectHostSetup): ProjectHostSetup { + const row: Record = isRecord(setup) ? setup : {} + const needsRepair = + !isString(row.id) || + !isString(row.projectId) || + !isString(row.hostId) || + !isString(row.repoId) || + !isString(row.path) || + !isString(row.displayName) || + !isTimestamp(row.createdAt) || + !isTimestamp(row.updatedAt) + if (!needsRepair) { + return setup + } + return { + ...row, + id: stringValue(row.id), + projectId: stringValue(row.projectId), + hostId: stringValue(row.hostId, LOCAL_EXECUTION_HOST_ID) as ProjectHostSetup['hostId'], + repoId: stringValue(row.repoId), + path: stringValue(row.path), + displayName: stringValue(row.displayName), + setupState: (row.setupState === undefined + ? 'error' + : row.setupState) as ProjectHostSetup['setupState'], + setupMethod: (row.setupMethod === undefined + ? 'legacy-repo' + : row.setupMethod) as ProjectHostSetup['setupMethod'], + createdAt: timestampValue(row.createdAt), + updatedAt: timestampValue(row.updatedAt) + } +} + +export function normalizeProjectRow(project: Project): Project { + const row: Record = isRecord(project) ? project : {} + const sourceRepoIdsConform = Array.isArray(row.sourceRepoIds) && row.sourceRepoIds.every(isString) + const needsRepair = + !isString(row.id) || + !isString(row.displayName) || + !isString(row.badgeColor) || + !sourceRepoIdsConform || + !isTimestamp(row.createdAt) || + !isTimestamp(row.updatedAt) + if (!needsRepair) { + return project + } + return { + ...row, + id: stringValue(row.id), + displayName: stringValue(row.displayName), + badgeColor: stringValue(row.badgeColor), + sourceRepoIds: Array.isArray(row.sourceRepoIds) + ? row.sourceRepoIds.filter((repoId): repoId is string => isString(repoId)) + : [], + createdAt: timestampValue(row.createdAt), + updatedAt: timestampValue(row.updatedAt) + } +} + +function normalizeRows(rows: T[], normalize: (row: T) => T): T[] { + if (!Array.isArray(rows)) { + return [] + } + let repaired: T[] | null = null + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]! + const normalized = normalize(row) + if (normalized !== row && !repaired) { + repaired = rows.slice(0, index) + } + repaired?.push(normalized) + } + return repaired ?? rows +} + +export function normalizeProjectHostSetupRows(setups: ProjectHostSetup[]): ProjectHostSetup[] { + return normalizeRows(setups, normalizeProjectHostSetupRow) +} + +export function normalizeProjectRows(projects: Project[]): Project[] { + return normalizeRows(projects, normalizeProjectRow) +} diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 8c3e5d15643..090f421ccdd 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -1,4 +1,8 @@ import { getRepoExecutionHostId } from './execution-host' +import { + normalizeProjectHostSetupRow, + normalizeProjectRow +} from './project-catalog-row-normalization' import { normalizeGitHubRemoteHost } from './git-remote-host-alias' import { githubRepoIdentityKey, isDefaultGitHubHost } from './github/repository-identity-key' import type { Project, ProjectHostSetup, ProjectProviderIdentity } from './project-types' @@ -312,7 +316,9 @@ export function projectHostSetupProjectionFromRepos( const project = existing ? mergeProjectRepo(existing.project, repo) : createProjectFromRepo(repo) - const setup = createSetupFromRepo(repo, projectId) + // Why normalize here: a repo row is untrusted persisted/wire data too, and these + // constructors copy repo.path / repo.id straight onto fields consumers call .trim() on. + const setup = normalizeProjectHostSetupRow(createSetupFromRepo(repo, projectId)) projectById.set(projectId, { project }) @@ -320,7 +326,7 @@ export function projectHostSetupProjectionFromRepos( } return { - projects: [...projectById.values()].map((entry) => entry.project), + projects: [...projectById.values()].map((entry) => normalizeProjectRow(entry.project)), setups } } From 3558cf943f2284b6d3335df386d711cf1adf5b10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:05:12 -0700 Subject: [PATCH 028/118] fix(codex): heal WSL hooks before typed launches (#16535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): heal WSL hooks before typed launches * test(codex): keep launcher fixture type-safe on Windows * fix(build): list codex-home-wsl-env in the CLI typecheck project `managed-home-shell-preflight.ts` is already in the CLI project's include list and now imports `wslCodexRuntimeHomeForGuestHome` from `src/main/pty/codex-home-wsl-env.ts`, which the list did not cover — TS6307, so the CLI typecheck failed on every push. Added the single module rather than a `src/main/pty/**` glob: it is a 31-line leaf with no imports of its own, so it does not widen what the CLI bundle can reach. * fix(codex): converge the two WSL hook install lanes onto one writer Two independent readiness reviews agreed the Orca-terminal boundary holds, but Codex Sol found a P1 the other rated P2: the new just-in-time repair raced the existing relay installer and the two produced DIFFERENT hook and trust representations for the same managed home. Two unserialized writers emitting different formats is worse than the bug this PR fixes, because it fails intermittently rather than cleanly — a pane works or does not depending on which lane won. - Relay Codex installs now delegate to the runtime-home writer, so there is one canonical representation instead of two. Redirected scripts use the runtime path, the readable wrapper, and the prepended group. - `installForRuntimeHomeSerialized` puts every asynchronous WSL caller for a given home on one queue (`wslInstallQueues`), so concurrent panes cannot interleave writes. Also rewrites the stale pin test the new `-x` guard broke. It asserted the defect — "would run the impostor if the preflight carried an unqualified command name", expecting the hijack marker to exist. The guard is a security improvement, so the test now asserts the contract: an unqualified preflight is skipped and the marker is never written. Rewritten to the new behavior, not loosened or deleted. 818 tests pass across the affected suites; typecheck clean. The changed-file quality gate could not run locally — its pnpm engine-warning JSON parser fails under Node 26 — so CI covers it. The boundary both reviews verified is untouched: paired/relay/mobile clients stay hard-blocked from the RPC, params remain shape-locked to the managed home suffix with traversal rejection, nothing is written outside the managed home, and macOS/Linux stay inert. * fix(codex): serialize resolved WSL hook homes * fix(codex): recover managed WSL homes after restart * fix(wsl): translate Codex preflight through WSLENV * fix(cli): cover bounded WSL Codex repair * fix(codex): coalesce duplicate WSL hook repairs * fix(codex): verify reconstructed WSL homes --- src/cli/handlers/agent-hooks.test.ts | 28 ++++ src/cli/handlers/agent-hooks.ts | 19 +++ .../daemon-bash-rcfile.txt | 2 +- .../daemon-fish-init.txt | 2 +- .../daemon-zsh-zshenv.txt | 2 +- .../local-bash-rcfile.txt | 2 +- .../local-fish-init.txt | 2 +- .../local-zsh-zshenv.txt | 2 +- .../remote-hook-service-installers.test.ts | 2 +- src/main/agent-hooks/wsl-hook-fs-adapter.ts | 31 +++- src/main/agent-hooks/wsl-hook-relay-deps.ts | 8 + .../wsl-hook-relay-guest-install.ts | 6 +- .../wsl-hook-relay-live.integration.test.ts | 17 +- .../wsl-hook-relay-manager.test.ts | 39 ++++- .../agent-hooks/wsl-hook-relay-manager.ts | 35 ++++- .../codex-trust-config-mutation-queue.test.ts | 32 ++++ .../codex/hook-service-wsl-runtime.test.ts | 63 +++++++- src/main/codex/hook-service.ts | 43 +++++- .../managed-home-shell-preflight.test.ts | 137 +++++++++++++++++ .../codex/managed-wsl-codex-home-registry.ts | 115 ++++++++++++++ .../codex/managed-wsl-home-shell-preflight.ts | 75 +++++++++ .../ipc/pty-spawn-env-terminal-basics.test.ts | 27 ++++ src/main/ipc/pty/host-env/assembly.ts | 2 +- ...dex-preflight-profile-path-rewrite.test.ts | 6 +- .../pty/codex-shell-launch-preflight.test.ts | 71 ++++++++- src/main/pty/codex-shell-launch-preflight.ts | 15 +- src/main/pty/wsl-orca-env.test.ts | 2 +- src/main/pty/wsl-orca-env.ts | 1 + .../runtime/rpc/methods/agent-hooks.test.ts | 145 ++++++++++++++++++ src/main/runtime/rpc/methods/agent-hooks.ts | 38 +++++ src/main/runtime/rpc/methods/index.ts | 2 + 31 files changed, 917 insertions(+), 54 deletions(-) create mode 100644 src/main/codex/managed-wsl-codex-home-registry.ts create mode 100644 src/main/codex/managed-wsl-home-shell-preflight.ts create mode 100644 src/main/runtime/rpc/methods/agent-hooks.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-hooks.ts diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index c42531364a8..4fcc186b0d8 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -92,6 +92,7 @@ describe('agent hooks CLI handler', () => { }) afterEach(() => { + vi.unstubAllEnvs() vi.restoreAllMocks() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -139,6 +140,33 @@ describe('agent hooks CLI handler', () => { }) }) + it('forwards WSL pane routing to the runtime exactly once without using the host installer', async () => { + const home = '/home/jin/.local/share/orca/codex-runtime-home/home' + vi.stubEnv('CODEX_HOME', home) + vi.stubEnv('ORCA_CODEX_HOME', home) + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu-24.04') + callMock.mockResolvedValue({ result: { state: 'installed' } }) + + await main(['agent', 'hooks', 'prepare-codex'], userDataPath) + + expect(callMock).toHaveBeenCalledExactlyOnceWith( + 'agentHooks.prepareCodexForWslPane', + { codexHome: home, orcaCodexHome: home, wslDistro: 'Ubuntu-24.04' }, + { timeoutMs: 50_000 } + ) + expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() + }) + + it('fails open when WSL runtime preparation is unavailable', async () => { + vi.stubEnv('CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home') + vi.stubEnv('ORCA_CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home') + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + callMock.mockRejectedValue(new Error('method_not_found')) + + await expect(main(['agent', 'hooks', 'prepare-codex'], userDataPath)).resolves.toBeUndefined() + expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() + }) + it('honors Codex-specific disablement when the runtime is unavailable', async () => { const state = getDefaultPersistedState(userDataPath) state.settings.disabledTuiAgents = ['codex'] diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 57419518d4b..bf44211b4a7 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -28,6 +28,9 @@ type AgentHookCommandResult = { statuses: AgentHookInstallStatus[] } +// Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap. +const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000 + function getDataPath(): string { const userDataPath = getDefaultUserDataPath() const indexPath = join(userDataPath, 'orca-profile-index.json') @@ -207,6 +210,22 @@ async function setAgentHooksEnabled( export const AGENT_HOOK_HANDLERS: Record = { 'agent hooks prepare-codex': async ({ client }) => { + if (process.env.WSL_DISTRO_NAME?.trim()) { + try { + await client.call( + 'agentHooks.prepareCodexForWslPane', + { + codexHome: process.env.CODEX_HOME ?? '', + orcaCodexHome: process.env.ORCA_CODEX_HOME ?? '', + wslDistro: process.env.WSL_DISTRO_NAME + }, + { timeoutMs: WSL_CODEX_PREPARE_TIMEOUT_MS } + ) + } catch { + // Best effort: old or unavailable runtimes must not block Codex launch. + } + return + } const settings = await readHookSettings(client) await prepareManagedCodexHomeBeforeShellLaunch({ userDataPath: getDefaultUserDataPath(), diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 079d23ec734..63748ee93af 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -72,7 +72,7 @@ fi # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then +if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt index 9c8b9179a81..660c8f2d1f9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt @@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt builtin printf "\033]777;orca-shell-ready\007" functions -e __orca_shell_ready_marker end -if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file +if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true command codex $argv diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index f0ec07cfcfd..e734f8c1867 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -110,7 +110,7 @@ __orca_deferred_init() { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" - if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index 895434cc688..9d1e2922b45 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -75,7 +75,7 @@ fi # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then +if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt index 9c8b9179a81..660c8f2d1f9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt @@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt builtin printf "\033]777;orca-shell-ready\007" functions -e __orca_shell_ready_marker end -if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file +if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true command codex $argv diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 92802c53dca..af7cab85587 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -110,7 +110,7 @@ __orca_deferred_init() { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" - if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index bcb446b10ee..a1168326ac2 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -284,7 +284,7 @@ describe('remote hook service installers', () => { hooks: Record } expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain( - '/home/dev/.orca/agent-hooks/codex-hook.sh' + '/home/dev/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh' ) expect(fs.files.get(`${runtimeHome}/config.toml`)).toContain( `${runtimeHome}/hooks.json:stop:0:0` diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index 12563b65bd8..ce2e9dd75f7 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -12,19 +12,23 @@ import { type ManagedHookDetectionSettings } from './managed-hook-detection-commands' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' /** Run the shared remote hook installers against a WSL guest over the relay's fs bridge. */ export async function installWslGuestHooks(options: { mux: SshChannelMultiplexer guestHome: string + codexHomePath: string | null distro: string installHooks: typeof installRemoteManagedAgentHooks settings: ManagedHookDetectionSettings warn: (message: string) => void + /** Canonical runtime-host installer for redirected Codex homes. */ + installCodex: (runtimeHomePath: string, distro: string) => Promise }): Promise { - const { mux, guestHome, distro, installHooks, settings, warn } = options + const { mux, guestHome, codexHomePath, distro, installHooks, settings, warn, installCodex } = + options let agents try { const detected = (await mux.request('preflight.detectAgents', { @@ -42,12 +46,25 @@ export async function installWslGuestHooks(options: { if (agents.length === 0) { return } + if (agents.includes('codex') && codexHomePath) { + try { + const status = await installCodex(codexHomePath, distro) + if (status?.state === 'error') { + warn( + `[agent-hooks] WSL Codex hook install for '${distro}' failed: ${status.detail ?? 'unknown error'}` + ) + } + } catch (error) { + warn( + `[agent-hooks] WSL Codex hook install for '${distro}' failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + // Codex is redirected into the runtime home and must use the canonical + // runtime-host writer above; the relay adapter owns all other agents. + const remoteAgents = agents.filter((agent) => agent !== 'codex') const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, { - agents, - // WSL Codex launches use Orca's managed runtime CODEX_HOME, not ~/.codex. - // Keep relay hooks in that active home so status callbacks are received. - codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome), - deferTrustUntilConfigToml: true + agents: remoteAgents }) const failed = results.filter((r) => r.state === 'error').length if (failed > 0) { diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index a7f1ef54bb4..dcff332e57b 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -9,6 +9,8 @@ import { agentHookServer } from './server' import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { getOpenCodePluginSource } from '../opencode/hook-service' +import { codexHookService } from '../codex/hook-service' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { PluginSources } from '../../relay/plugin-overlay' import { isWslDistroRunning, @@ -61,6 +63,7 @@ export type WslHookRelayManagerDeps = { waitForSentinel: typeof waitForWslRelaySentinel ingest: (envelope: Record, connectionId: string) => void installHooks: typeof installRemoteManagedAgentHooks + installCodex: (runtimeHomePath: string, distro: string) => Promise managedHookSettings: () => ManagedHookDetectionSettings /** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */ pluginSources: () => PluginSources @@ -102,6 +105,11 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { connectionId ), installHooks: installRemoteManagedAgentHooks, + installCodex: (runtimeHomePath, distro) => + codexHookService.installForRuntimeHomeSerialized(runtimeHomePath, { + runtime: 'wsl', + wslDistro: distro + }), managedHookSettings: () => null, // Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later. pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }), diff --git a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts index d7e59651a52..84cf4bfa57a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts +++ b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts @@ -6,13 +6,14 @@ import type { ManagedHookDetectionSettings } from './managed-hook-detection-comm import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' import { installWslGuestHooks } from './wsl-hook-fs-adapter' -import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps' +import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { PluginSources } from '../../relay/plugin-overlay' /** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */ type GuestInstallDeps = { installHooks: typeof installRemoteManagedAgentHooks + installCodex: WslHookRelayManagerDeps['installCodex'] managedHookSettings: () => ManagedHookDetectionSettings pluginSources: () => PluginSources warn: (message: string) => void @@ -23,6 +24,7 @@ type GuestInstallState = { distro: string mux?: SshChannelMultiplexer guestHome?: string + codexHomePath?: string opencodeOverlayDir?: string lastInstallAt?: number } @@ -37,8 +39,10 @@ export async function runWslRelayGuestInstall( await installWslGuestHooks({ mux, guestHome, + codexHomePath: state.codexHomePath ?? null, distro: state.distro, installHooks: deps.installHooks, + installCodex: deps.installCodex, settings: deps.managedHookSettings(), warn: deps.warn }) diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts index ea6856e9862..365e4d5fcd8 100644 --- a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -13,7 +13,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from './server' import { WslHookRelayManager } from './wsl-hook-relay-manager' -import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' +import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' +import { codexHookService } from '../codex/hook-service' const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl') const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js') @@ -75,6 +76,7 @@ describe.skipIf(process.platform === 'win32')( const server = orcaServer const warns: string[] = [] + const codexHome = join(fakeHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') manager = new WslHookRelayManager({ platform: () => 'win32', remoteHooksEnabled: () => true, @@ -102,6 +104,11 @@ describe.skipIf(process.platform === 'win32')( envelope as Parameters[0], connectionId ), + installCodex: (runtimeHomePath) => + codexHookService.installRemote(createManagedHookLocalFilesystem(), fakeHome, { + codexHomeDir: runtimeHomePath, + deferTrustUntilConfigToml: true + }), managedHookSettings: () => ({ agentCmdOverrides: { claude: process.execPath, @@ -112,15 +119,17 @@ describe.skipIf(process.platform === 'win32')( transientRetryDelayMs: 1 }) - manager.ensureForDistro('LiveDistro') + manager.ensureForDistro('LiveDistro', codexHome) // Waiting on Codex's artifact (not Claude's, which is written first) keeps the // assertions behind the still-running 14-agent installer loop. - const codexHome = wslCodexRuntimeHomeForGuestHome(fakeHome) await vi.waitFor(() => expect(existsSync(join(codexHome, 'hooks.json'))).toBe(true), { timeout: 15_000 }) - expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true) + await vi.waitFor( + () => expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true), + { timeout: 15_000 } + ) const claudeScript = readFileSync( join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'), 'utf8' diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 797b461d133..9d92f5d131a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -147,6 +147,8 @@ describe('WslHookRelayManager', () => { // hosts — installHooks is mocked here, so the fs bridge only ever serves // the wslfs.home request and never touches the real filesystem. const home = '/home/wsl-test-user' + const codexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\wsl-test-user\\.local\\share\\orca\\codex-runtime-home\\home' const opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe` let harnesses: GuestHarness[] @@ -229,6 +231,13 @@ describe('WslHookRelayManager', () => { waitForSentinel: vi.fn(async () => guestTransport()), ingest: vi.fn(), installHooks: vi.fn(async () => []), + installCodex: vi.fn(async () => ({ + agent: 'codex' as const, + state: 'installed' as const, + configPath: `${home}/.local/share/orca/codex-runtime-home/home/hooks.json`, + managedHooksPresent: true, + detail: null + })), managedHookSettings: () => null, pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }), warn: vi.fn(), @@ -240,14 +249,14 @@ describe('WslHookRelayManager', () => { it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => { const { manager, deps } = createManager({}) - manager.ensureForDistro('Ubuntu') - manager.ensureForDistro('Ubuntu') + manager.ensureForDistro('Ubuntu', codexHome) + manager.ensureForDistro('Ubuntu', codexHome) await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + expect(deps.installCodex).toHaveBeenCalledWith(codexHome, 'Ubuntu') + // Codex is owned by the canonical runtime-host writer, not the relay adapter. expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, { - agents: ['codex'], - codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`, - deferTrustUntilConfigToml: true + agents: [] }) expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe( @@ -272,9 +281,23 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) + it('reinstalls into a newly resolved runtime home without restarting the relay', async () => { + const { manager, deps } = createManager({}) + manager.ensureForDistro('Ubuntu', codexHome) + await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(1)) + const nextHome = codexHome.replace('codex-runtime-home', 'codex-accounts\\account-2') + + manager.ensureForDistro('ubuntu', nextHome) + await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2)) + + expect(deps.installCodex).toHaveBeenLastCalledWith(nextHome, 'Ubuntu') + expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + manager.disposeAll() + }) + it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => { const { manager } = createManager({}) - manager.ensureForDistro('Ubuntu') + manager.ensureForDistro('Ubuntu', codexHome) await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir)) manager.disposeAll() }) @@ -397,7 +420,7 @@ describe('WslHookRelayManager', () => { it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => { const settings = { agentStatusHooksEnabled: true } const { manager, deps } = createManager({ managedHookSettings: () => settings }) - manager.ensureForDistro('Ubuntu') + manager.ensureForDistro('Ubuntu', codexHome) await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) settings.agentStatusHooksEnabled = false @@ -414,6 +437,8 @@ describe('WslHookRelayManager', () => { settings.agentStatusHooksEnabled = true manager.resumeStoppedRelays() await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2)) + expect(deps.installCodex).toHaveBeenLastCalledWith(codexHome, 'Ubuntu') manager.disposeAll() }) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 74d163ca42f..1a414df5898 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -29,6 +29,10 @@ import { WSL_HOOK_FS_METHODS, wslHookRelayEndpointFilePath } from '../../shared/wsl-hook-relay-contract' +import { + recordManagedWslCodexHome, + wslRuntimeHomePathsEqual +} from '../codex/managed-wsl-codex-home-registry' type DistroState = { /** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */ @@ -37,6 +41,7 @@ type DistroState = { child?: ChildProcessWithoutNullStreams mux?: SshChannelMultiplexer guestHome?: string + codexHomePath?: string guestEndpointFilePath?: string opencodeOverlayDir?: string failures: number @@ -52,7 +57,7 @@ export class WslHookRelayManager { private recovery: WslRelayRecovery private states = new Map() /** Distros a hooks-off teardown stopped, so re-enabling can put them back. */ - private stoppedByHooksOff = new Set() + private stoppedByHooksOff = new Map() private defaultDistro: string | null = null private disposed = false private warnedBundleMissing = false @@ -64,7 +69,7 @@ export class WslHookRelayManager { warn: (message) => this.deps.warn(message), isDisposed: () => this.disposed, isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state, - restart: (distro) => this.ensureForDistro(distro), + restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath), dropState: (state) => { // Why: identity-guarded — a fresh ensure() may own this key by now; // deleting by key alone would orphan its live relay child. @@ -81,11 +86,11 @@ export class WslHookRelayManager { } /** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */ - ensureForDistro(distro: string | null): void { + ensureForDistro(distro: string | null, codexHomePath?: string | null): void { if (this.disposed || !isWslHookRelayAllowed(this.deps)) { return } - void this.ensureInternal(distro).catch((err) => { + void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => { const detail = err instanceof Error ? err.message : String(err) this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`) }) @@ -118,7 +123,7 @@ export class WslHookRelayManager { state.mux?.dispose() state.child?.kill() if (!permanent) { - this.stoppedByHooksOff.add(state.distro) + this.stoppedByHooksOff.set(state.distro, state.codexHomePath) } } this.states.clear() @@ -129,26 +134,39 @@ export class WslHookRelayManager { resumeStoppedRelays(): void { const distros = [...this.stoppedByHooksOff] this.stoppedByHooksOff.clear() - for (const distro of distros) { + for (const [distro, codexHomePath] of distros) { void this.deps .isDistroRunning(distro) .then((running) => { if (running) { - this.ensureForDistro(distro) + this.ensureForDistro(distro, codexHomePath) } }) .catch(() => undefined) } } - private async ensureInternal(requestedDistro: string | null): Promise { + private async ensureInternal( + requestedDistro: string | null, + requestedCodexHomePath?: string + ): Promise { const distro = requestedDistro ?? (await this.resolveDefaultDistro()) if (!distro || this.disposed) { return } const key = wslHookRelayStateKey(distro) const existing = this.states.get(key) + if (requestedCodexHomePath) { + recordManagedWslCodexHome(distro, requestedCodexHomePath) + } if (existing) { + if ( + requestedCodexHomePath && + !wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath) + ) { + existing.codexHomePath = requestedCodexHomePath + existing.lastInstallAt = 0 + } if (existing.phase === 'running') { void maybeRerunWslRelayGuestInstall(this.deps, existing) return @@ -184,6 +202,7 @@ export class WslHookRelayManager { // Why: instance-keyed and on the distro's persistent fs, so it outlives a relay // crash — dropping it would blank status on panes spawned mid-relaunch. opencodeOverlayDir: existing?.opencodeOverlayDir, + codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath, cooldownUntil: 0 } this.states.set(key, state) diff --git a/src/main/codex/codex-trust-config-mutation-queue.test.ts b/src/main/codex/codex-trust-config-mutation-queue.test.ts index e2d775b4ed7..f07c047c203 100644 --- a/src/main/codex/codex-trust-config-mutation-queue.test.ts +++ b/src/main/codex/codex-trust-config-mutation-queue.test.ts @@ -108,4 +108,36 @@ describe('runExclusivelyForCodexTrustConfig', () => { await queued expect(secondStarted).toBe(true) }) + + it('coalesces WSL UNC aliases without folding the case-sensitive Linux path', async () => { + const aliasGate = deferred() + let aliasStarted = false + const blockedAlias = runExclusivelyForCodexTrustConfig( + String.raw`\\wsl.localhost\Ubuntu\home\Alice\.codex\config.toml`, + () => aliasGate.promise + ) + const queuedAlias = runExclusivelyForCodexTrustConfig( + String.raw`\\wsl$\ubuntu\home\Alice\.codex\config.toml`, + () => { + aliasStarted = true + return Promise.resolve() + } + ) + await Promise.resolve() + expect(aliasStarted).toBe(false) + + let distinctStarted = false + await runExclusivelyForCodexTrustConfig( + String.raw`\\wsl.localhost\Ubuntu\home\alice\.codex\config.toml`, + async () => { + distinctStarted = true + } + ) + expect(distinctStarted).toBe(true) + + aliasGate.resolve() + await blockedAlias + await queuedAlias + expect(aliasStarted).toBe(true) + }) }) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 5694e4fe9bb..05baab50912 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -16,6 +16,7 @@ import { type CodexTrustEntry } from './config-toml-trust' import { + CodexHookService, _internals, createCodexWslRuntimeHookInstallPlan, type CodexWslRuntimeHookInstallPlan @@ -82,7 +83,67 @@ function expectedManagedCommand(scriptPath: string): string { } describe('Codex WSL runtime hook install', () => { - it('plans WSL hook files with Linux command and trust paths', async () => { + it('coalesces aliases of one runtime home without blocking independent homes', async () => { + const service = new CodexHookService() + const releases: (() => void)[] = [] + const started: string[] = [] + vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => { + started.push(runtimeHomePath!) + await new Promise((resolve) => releases.push(resolve)) + return null + }) + const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home' + const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost') + const independent = firstHome.replace('\\Alice\\', '\\Bob\\') + + const first = service.installForRuntimeHomeSerialized(firstHome) + const second = service.installForRuntimeHomeSerialized(alias) + const third = service.installForRuntimeHomeSerialized(independent) + await vi.waitFor(() => expect(started).toEqual([firstHome, independent])) + expect(second).toBe(first) + + releases.splice(0).forEach((release) => release()) + await Promise.all([first, second, third]) + expect(started).toEqual([firstHome, independent]) + }) + + it('does not coalesce one drive-backed home across WSL distros', async () => { + const service = new CodexHookService() + const started: string[] = [] + vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (_runtimeHome, target) => { + started.push(target?.wslDistro ?? '') + return null + }) + const home = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home' + + await Promise.all([ + service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Ubuntu' }), + service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Debian' }) + ]) + + expect(started).toEqual(['Ubuntu', 'Debian']) + }) + + it('keeps case-distinct Linux runtime homes on separate queues', async () => { + const service = new CodexHookService() + const started: string[] = [] + vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => { + started.push(runtimeHomePath!) + return null + }) + const upper = + '\\\\wsl.localhost\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home' + const lower = upper.replace('\\Alice\\', '\\alice\\') + + await Promise.all([ + service.installForRuntimeHomeSerialized(upper), + service.installForRuntimeHomeSerialized(lower) + ]) + + expect(started).toEqual([upper, lower]) + }) + + it('plans WSL hook files with Linux command and trust paths', () => { const runtimeHome = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 0ea7837f154..4e58f947dee 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1075,6 +1075,7 @@ export class CodexHookService { } private readonly wslReconciliationGeneration = new Map() + private readonly wslInstallsInFlight = new Map>() private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number { if (!runtimeHomePath) { @@ -1171,6 +1172,30 @@ export class CodexHookService { } } + installForRuntimeHomeSerialized( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): Promise { + if (!runtimeHomePath) { + return Promise.resolve(null) + } + const targetKey = target?.runtime === 'wsl' ? target.wslDistro?.trim().toLowerCase() : '' + const key = `${getWslReconciliationKey(runtimeHomePath)}\0${targetKey ?? ''}` + const active = this.wslInstallsInFlight.get(key) + if (active) { + return active + } + const install = this.installForRuntimeHome(runtimeHomePath, target) + this.wslInstallsInFlight.set(key, install) + const clear = (): void => { + if (this.wslInstallsInFlight.get(key) === install) { + this.wslInstallsInFlight.delete(key) + } + } + void install.then(clear, clear) + return install + } + refreshRuntimeUserHooksForRuntimeHome( runtimeHomePath: string | null | undefined, target?: CodexWslRuntimeHookTarget @@ -1486,7 +1511,13 @@ export class CodexHookService { options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex` const remoteConfigPath = `${codexHomeBase}/hooks.json` const remoteTomlPath = `${codexHomeBase}/config.toml` - const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh` + // Redirected WSL homes must use the same script location and command shape + // as the runtime installer; two representations of one hooks.json race + // into stale trust keys. Plain SSH keeps its guest-home script contract. + const redirectedCodexHome = options?.codexHomeDir?.replace(/\/$/, '') + const remoteScriptPath = redirectedCodexHome + ? `${redirectedCodexHome}/.orca/agent-hooks/codex-hook.sh` + : `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh` try { const config = await readHooksJsonRemote(sftp, remoteConfigPath) if (!config) { @@ -1499,7 +1530,9 @@ export class CodexHookService { } } - const command = wrapPosixHookCommand(remoteScriptPath) + const command = redirectedCodexHome + ? wrapReadablePosixHookCommand(remoteScriptPath) + : wrapPosixHookCommand(remoteScriptPath) const nextHooks = { ...config.hooks } const managedEvents = new Set(CODEX_EVENTS) const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') @@ -1523,11 +1556,13 @@ export class CodexHookService { const definition: HookDefinition = { hooks: [buildManagedCommandHook(command)] } - nextHooks[eventName] = [...cleaned, definition] + nextHooks[eventName] = redirectedCodexHome + ? [definition, ...cleaned] + : [...cleaned, definition] trustEntries.push({ sourcePath: remoteConfigPath, eventLabel: CODEX_EVENT_LABEL[eventName], - groupIndex: cleaned.length, + groupIndex: redirectedCodexHome ? 0 : cleaned.length, handlerIndex: 0, command, timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS diff --git a/src/main/codex/managed-home-shell-preflight.test.ts b/src/main/codex/managed-home-shell-preflight.test.ts index 8cc519d3c79..31a98a30b2f 100644 --- a/src/main/codex/managed-home-shell-preflight.test.ts +++ b/src/main/codex/managed-home-shell-preflight.test.ts @@ -2,10 +2,25 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' + +const { wslRealpathMock } = vi.hoisted(() => ({ + wslRealpathMock: vi.fn(async (path: string) => path) +})) + +vi.mock('node:fs/promises', () => ({ realpath: wslRealpathMock })) + import { prepareManagedCodexHomeBeforeShellLaunch, resolveManagedCodexShellPreflightHome } from './managed-home-shell-preflight' +import { + prepareManagedWslCodexHomeBeforeShellLaunch, + resolveManagedWslCodexShellPreflightTarget +} from './managed-wsl-home-shell-preflight' +import { + _internals as managedWslHomeRegistryInternals, + recordManagedWslCodexHome +} from './managed-wsl-codex-home-registry' const roots: string[] = [] @@ -16,6 +31,8 @@ function makeRoot(): string { } afterEach(() => { + wslRealpathMock.mockClear() + managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes() for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } @@ -160,3 +177,123 @@ describe('managed Codex shell preflight', () => { ).toBeNull() }) }) + +describe('managed WSL Codex shell preflight', () => { + const home = '/home/jin/.local/share/orca/codex-runtime-home/home' + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' + const env = { + CODEX_HOME: home, + ORCA_CODEX_HOME: home, + WSL_DISTRO_NAME: 'Ubuntu-24.04' + } + + it('targets the pane-selected managed home through its UNC twin', () => { + recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) + expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({ + runtimeHomePath: runtimeHome, + wslDistro: 'Ubuntu-24.04' + }) + }) + + it('reconstructs a validated managed home when a restarted runtime has no record', () => { + expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({ + runtimeHomePath: runtimeHome, + wslDistro: 'Ubuntu-24.04' + }) + }) + + it('installs once through the WSL runtime-home lane while hooks are enabled', async () => { + recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) + const status = { + agent: 'codex' as const, + state: 'installed' as const, + configPath: 'config.toml', + managedHooksPresent: true, + detail: null + } + const install = vi.fn(() => status) + + await expect( + prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: true, install }) + ).resolves.toBe(status) + expect(install).toHaveBeenCalledExactlyOnceWith(runtimeHome, { + runtime: 'wsl', + wslDistro: 'Ubuntu-24.04' + }) + + await expect( + prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: false, install }) + ).resolves.toBeNull() + expect(install).toHaveBeenCalledTimes(1) + }) + + it.each([ + [ + 'a user home', + { ...env, CODEX_HOME: '/home/jin/.codex', ORCA_CODEX_HOME: '/home/jin/.codex' } + ], + ['unequal routing markers', { ...env, ORCA_CODEX_HOME: `${home}-other` }], + [ + 'a parent traversal', + { + ...env, + CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`, + ORCA_CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}` + } + ], + [ + 'a dot segment', + { + ...env, + CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`, + ORCA_CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}` + } + ], + [ + 'a host path', + { ...env, CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' } + ], + ['a missing distro', { ...env, WSL_DISTRO_NAME: '' }], + ['a distro path escape', { ...env, WSL_DISTRO_NAME: 'Ubuntu\\..\\host' }], + [ + 'a repeated separator', + { + ...env, + CODEX_HOME: home.replace('/jin/', '/jin//'), + ORCA_CODEX_HOME: home.replace('/jin/', '/jin//') + } + ] + ])('rejects %s', (_label, candidate) => { + recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) + expect(resolveManagedWslCodexShellPreflightTarget(candidate)).toBeNull() + }) + + it('preserves a recorded runtime spelling for a managed account home', () => { + const directHome = '/home/jin/.local/share/orca/codex-accounts/account-1/home' + const directRuntimeHome = + '\\\\wsl$\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-accounts\\account-1\\home' + recordManagedWslCodexHome('Ubuntu-24.04', directRuntimeHome) + + expect( + resolveManagedWslCodexShellPreflightTarget({ + CODEX_HOME: directHome, + ORCA_CODEX_HOME: directHome, + WSL_DISTRO_NAME: 'ubuntu-24.04' + }) + ).toEqual({ runtimeHomePath: directRuntimeHome, wslDistro: 'ubuntu-24.04' }) + }) + + it('never authorizes a WSL system home even when it was offered by a PTY lane', () => { + const systemHome = '/home/jin/.codex' + recordManagedWslCodexHome('Ubuntu-24.04', '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.codex') + + expect( + resolveManagedWslCodexShellPreflightTarget({ + CODEX_HOME: systemHome, + ORCA_CODEX_HOME: systemHome, + WSL_DISTRO_NAME: 'Ubuntu-24.04' + }) + ).toBeNull() + }) +}) diff --git a/src/main/codex/managed-wsl-codex-home-registry.ts b/src/main/codex/managed-wsl-codex-home-registry.ts new file mode 100644 index 00000000000..3641f991d24 --- /dev/null +++ b/src/main/codex/managed-wsl-codex-home-registry.ts @@ -0,0 +1,115 @@ +import { win32 as pathWin32 } from 'node:path' +import { + foldWslUncPathCaseInsensitiveParts, + parseWslUncPath, + toWindowsWslPath +} from '../../shared/wsl-paths' + +type ManagedWslCodexHome = { + runtimeHomePath: string + linuxHomePath: string +} + +const managedHomesByDistro = new Map>() + +function distroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +export function isAbsolutePosixPathWithoutDotSegments(value: string): boolean { + const segments = value.split('/').slice(1) + return ( + value.startsWith('/') && + !value.startsWith('//') && + !value.includes('\\') && + segments.every((segment) => segment && segment !== '.' && segment !== '..') + ) +} + +function isOrcaManagedWslCodexHome(linuxHomePath: string): boolean { + const segments = linuxHomePath.split('/').filter(Boolean) + const orcaIndex = segments.findIndex( + (segment, index) => + segment === 'orca' && segments[index - 1] === 'share' && segments[index - 2] === '.local' + ) + if (orcaIndex === -1) { + return false + } + const tail = segments.slice(orcaIndex + 1) + return ( + (tail.length === 2 && tail[0] === 'codex-runtime-home' && tail[1] === 'home') || + (tail.length === 3 && tail[0] === 'codex-accounts' && Boolean(tail[1]) && tail[2] === 'home') + ) +} + +function linuxHomeForRuntimePath(runtimeHomePath: string, distro: string): string | null { + const wsl = parseWslUncPath(runtimeHomePath) + if (wsl) { + return wsl.distro.toLowerCase() === distroKey(distro) ? wsl.linuxPath : null + } + const drive = runtimeHomePath.match(/^([A-Za-z]):[/\\](.*)$/) + return drive ? `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` : null +} + +export function recordManagedWslCodexHome(distro: string, runtimeHomePath: string): void { + const normalizedDistro = distro.trim() + const linuxHomePath = linuxHomeForRuntimePath(runtimeHomePath, normalizedDistro) + if ( + !normalizedDistro || + /[\\/\r\n]/.test(normalizedDistro) || + !linuxHomePath || + !isAbsolutePosixPathWithoutDotSegments(linuxHomePath) || + !isOrcaManagedWslCodexHome(linuxHomePath) + ) { + return + } + const homes = managedHomesByDistro.get(distroKey(normalizedDistro)) ?? new Map() + homes.set(linuxHomePath, { runtimeHomePath, linuxHomePath }) + managedHomesByDistro.set(distroKey(normalizedDistro), homes) +} + +export function resolveRecordedManagedWslCodexHome( + distro: string, + linuxHomePath: string +): string | null { + if ( + !distro.trim() || + /[\\/\r\n]/.test(distro) || + !isAbsolutePosixPathWithoutDotSegments(linuxHomePath) + ) { + return null + } + return managedHomesByDistro.get(distroKey(distro))?.get(linuxHomePath)?.runtimeHomePath ?? null +} + +export function resolveManagedWslCodexHome(distro: string, linuxHomePath: string): string | null { + if ( + !distro.trim() || + /[\\/\r\n]/.test(distro) || + !isAbsolutePosixPathWithoutDotSegments(linuxHomePath) || + !isOrcaManagedWslCodexHome(linuxHomePath) + ) { + return null + } + return ( + resolveRecordedManagedWslCodexHome(distro, linuxHomePath) ?? + toWindowsWslPath(linuxHomePath, distro) + ) +} + +export function wslRuntimeHomePathsEqual(left: string | undefined, right: string): boolean { + if (!left) { + return false + } + const leftWsl = foldWslUncPathCaseInsensitiveParts(left) + const rightWsl = foldWslUncPathCaseInsensitiveParts(right) + if (leftWsl || rightWsl) { + return leftWsl !== null && leftWsl === rightWsl + } + return pathWin32.normalize(left).toLowerCase() === pathWin32.normalize(right).toLowerCase() +} + +export const _internals = { + clearRecordedManagedWslCodexHomes: () => managedHomesByDistro.clear(), + isOrcaManagedWslCodexHome +} diff --git a/src/main/codex/managed-wsl-home-shell-preflight.ts b/src/main/codex/managed-wsl-home-shell-preflight.ts new file mode 100644 index 00000000000..854869ab05a --- /dev/null +++ b/src/main/codex/managed-wsl-home-shell-preflight.ts @@ -0,0 +1,75 @@ +import { realpath } from 'node:fs/promises' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { withTimeout } from '../../shared/promise-timeout-fallback' +import type { CodexWslRuntimeHookTarget } from './codex-wsl-hook-install-plan' +import { codexHookService } from './hook-service' +import { + isAbsolutePosixPathWithoutDotSegments, + resolveManagedWslCodexHome, + wslRuntimeHomePathsEqual +} from './managed-wsl-codex-home-registry' + +type WslShellPreflightEnvironment = { + CODEX_HOME?: string + ORCA_CODEX_HOME?: string + WSL_DISTRO_NAME?: string +} + +const WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS = 5_000 + +export type ManagedWslCodexShellPreflightTarget = { + runtimeHomePath: string + wslDistro: string +} + +export function resolveManagedWslCodexShellPreflightTarget( + env: WslShellPreflightEnvironment +): ManagedWslCodexShellPreflightTarget | null { + const codexHome = env.CODEX_HOME?.trim() + const orcaCodexHome = env.ORCA_CODEX_HOME?.trim() + const wslDistro = env.WSL_DISTRO_NAME?.trim() + if ( + !codexHome || + codexHome !== orcaCodexHome || + !wslDistro || + /[\\/\r\n]/.test(wslDistro) || + !isAbsolutePosixPathWithoutDotSegments(codexHome) + ) { + return null + } + const runtimeHomePath = resolveManagedWslCodexHome(wslDistro, codexHome) + return runtimeHomePath ? { runtimeHomePath, wslDistro } : null +} + +export async function prepareManagedWslCodexHomeBeforeShellLaunch(args: { + env: WslShellPreflightEnvironment + hooksEnabled: boolean + install?: ( + runtimeHomePath: string, + target: CodexWslRuntimeHookTarget + ) => AgentHookInstallStatus | Promise | null +}): Promise { + if (!args.hooksEnabled) { + return null + } + const target = resolveManagedWslCodexShellPreflightTarget(args.env) + if (!target) { + return null + } + const realHome = await withTimeout( + realpath(target.runtimeHomePath), + WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS, + null + ) + if (!realHome || !wslRuntimeHomePathsEqual(realHome, target.runtimeHomePath)) { + return null + } + const install = + args.install ?? + ((home: string, hookTarget: CodexWslRuntimeHookTarget) => + codexHookService.installForRuntimeHomeSerialized(home, hookTarget)) + return await install(target.runtimeHomePath, { + runtime: 'wsl', + wslDistro: target.wslDistro + }) +} diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 18fbf7019f2..54ec3e7663c 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -7,6 +7,7 @@ import { LocalPtyProvider } from '../providers/local-pty-provider' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -57,6 +58,32 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite() describe('spawn environment', () => { + it('passes the PTY-resolved Codex home to the WSL relay lane', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' + const ensureForDistro = vi + .spyOn(wslHookRelayManager, 'ensureForDistro') + .mockImplementation(() => {}) + + try { + buildPtyHostEnv( + 'pty-wsl', + {}, + { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: runtimeHome, + isWsl: true, + wslDistro: 'Ubuntu', + agentStatusHooksEnabled: true + } + ) + expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome) + } finally { + ensureForDistro.mockRestore() + } + }) + it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 569b40052df..cee3bf5c50b 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -122,7 +122,7 @@ export function buildPtyHostEnv( if (opts.isWsl === true) { // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. const distro = opts.wslDistro ?? null - wslHookRelayManager.ensureForDistro(distro) + wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath) const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) if (guestEndpoint) { baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint diff --git a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts index abf189de356..4649b5636a3 100644 --- a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts +++ b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts @@ -162,13 +162,13 @@ describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH' expect(readFileSync(fixture.codexMarker, 'utf-8').trim()).toBe('--alias-flag --version') }) - // Why: pins the defect itself, so a regression back to an unqualified name fails here. - it('would run the impostor if the preflight carried an unqualified command name', () => { + // Why: an unqualified preflight is rejected so profile PATH entries cannot hijack it. + it('skips an unqualified preflight value while still launching codex', () => { const fixture = buildFixture() launchCodexThroughRcfile(fixture, 'orca') - expect(existsSync(fixture.hijackMarker)).toBe(true) + expect(existsSync(fixture.hijackMarker)).toBe(false) expect(existsSync(fixture.intendedMarker)).toBe(false) expect(existsSync(fixture.codexMarker)).toBe(true) }) diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index 2aff785446b..be8d84537c9 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -170,6 +170,30 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () ) }) + it('does not trigger a preflight outside an Orca terminal', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-plain-shell-')) + roots.push(root) + const bin = join(root, 'bin') + const marker = join(root, 'preflight-ran') + mkdirSync(bin) + writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n') + writeExecutable(join(bin, 'orca-test'), `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`) + + const output = execFileSync( + '/bin/bash', + ['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`], + { + encoding: 'utf-8', + env: { + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}` + } + } + ) + + expect(output.trim()).toBe('launched') + expect(existsSync(marker)).toBe(false) + }) + // Why a loop over it.each: an early `return` reported green on hosts without the // shell, so the zsh half silently never ran on Linux. skipIf reports it as a skip. for (const [shell, strict] of [ @@ -319,6 +343,24 @@ describe('Codex shell launch preflight command', () => { ).toBe(launcherPath) }) + it('carries the packaged Windows launcher for WSLENV path translation', () => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', 'orca.exe') + writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + isWsl: true, + managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home', + userDataPath, + resourcesPath, + platform: 'win32' + }) + ).toBe(launcherPath) + }) + it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => { const { userDataPath, resourcesPath } = makeCliRoot() writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') @@ -340,16 +382,12 @@ describe('Codex shell launch preflight command', () => { it.each([ { label: 'the launcher file is missing', create: null }, - { label: 'the launcher is not executable', create: 0o644 }, { label: 'the launcher path is a directory', create: 'directory' as const } ])('skips the preflight when $label', (config) => { const { userDataPath, resourcesPath } = makeCliRoot() const launcherPath = join(resourcesPath, 'bin', 'orca') if (config.create === 'directory') { mkdirSync(launcherPath) - } else if (config.create !== null) { - writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n') - chmodSync(launcherPath, config.create) } expect( @@ -364,6 +402,27 @@ describe('Codex shell launch preflight command', () => { ).toBeNull() }) + it.skipIf(process.platform === 'win32')( + 'skips the preflight when the launcher is not executable', + () => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', 'orca') + writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n') + chmodSync(launcherPath, 0o644) + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: 'darwin' + }) + ).toBeNull() + } + ) + it('skips the preflight when the packaged build exposes no resources root', () => { const { userDataPath } = makeCliRoot() @@ -381,7 +440,7 @@ describe('Codex shell launch preflight command', () => { it.each([ { hooksEnabled: false, isWsl: false, managedHomePath: '/managed/home' }, - { hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home' }, + { hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home', isPackaged: false }, { hooksEnabled: true, isWsl: false, managedHomePath: null } ])('does not enable an unsupported preflight for %o', (options) => { const { userDataPath, resourcesPath } = makeCliRoot() @@ -390,7 +449,7 @@ describe('Codex shell launch preflight command', () => { expect( resolveCodexShellLaunchPreflightCommand({ ...options, - isPackaged: true, + isPackaged: options.isPackaged ?? true, userDataPath, resourcesPath, platform: 'darwin' diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 4b2429f50f1..537933fcdba 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -29,7 +29,7 @@ export type CodexShellLaunchPreflightCommandOptions = { export function resolveCodexShellLaunchPreflightCommand( options: CodexShellLaunchPreflightCommandOptions ): string | null { - if (!options.hooksEnabled || options.isWsl || !options.managedHomePath) { + if (!options.hooksEnabled || !options.managedHomePath) { return null } const platform = options.platform ?? process.platform @@ -42,7 +42,14 @@ export function resolveCodexShellLaunchPreflightCommand( ...DEV_LAUNCHER_DIR, platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME ) - return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null + if (!candidate || !isExecutableFileOnDisk(candidate, platform)) { + return null + } + if (!options.isWsl) { + return candidate + } + // Why: WSLENV /p translates the verified Windows launcher with the distro's configured automount root. + return platform === 'win32' && options.isPackaged ? candidate : null } function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean { @@ -65,7 +72,7 @@ export function getPosixCodexShellLaunchPreflight(): string { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then +if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { @@ -78,7 +85,7 @@ unset __orca_codex_binary } export function getFishCodexShellLaunchPreflight(): string { - return `if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file + return `if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true command codex $argv diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 133844dec78..cc79e2bb054 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -85,7 +85,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') - expect(env.WSLENV).not.toContain('ORCA_CODEX_LAUNCH_PREFLIGHT') + expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 0fe855ba3b0..262df20b3b2 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -77,6 +77,7 @@ export function addOrcaWslInteropEnv(env: Record): void { // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. 'ORCA_SHELL_READY_ROOT/p', 'ORCA_CLI_COMMAND/u', + 'ORCA_CODEX_LAUNCH_PREFLIGHT/p', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/main/runtime/rpc/methods/agent-hooks.test.ts b/src/main/runtime/rpc/methods/agent-hooks.test.ts new file mode 100644 index 00000000000..5781045a1f7 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-hooks.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { isStreamingMethod, type RpcContext } from '../core' + +const { installForRuntimeHomeSerializedMock, realpathMock } = vi.hoisted(() => ({ + installForRuntimeHomeSerializedMock: vi.fn(), + realpathMock: vi.fn() +})) + +vi.mock('../../../codex/hook-service', () => ({ + codexHookService: { installForRuntimeHomeSerialized: installForRuntimeHomeSerializedMock } +})) +vi.mock('node:fs/promises', () => ({ realpath: realpathMock })) + +import { AGENT_HOOK_METHODS } from './agent-hooks' +import { + _internals as managedWslHomeRegistryInternals, + recordManagedWslCodexHome +} from '../../../codex/managed-wsl-codex-home-registry' + +const LINUX_HOME = '/home/jin/.local/share/orca/codex-runtime-home/home' +const RUNTIME_HOME = + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' + +function prepareMethod() { + const method = AGENT_HOOK_METHODS.find( + (candidate) => candidate.name === 'agentHooks.prepareCodexForWslPane' + ) + if (!method || isStreamingMethod(method)) { + throw new Error('Missing agentHooks.prepareCodexForWslPane request method') + } + return method +} + +function runtimeWithSettings(enabled = true, disabledTuiAgents: string[] = []): OrcaRuntimeService { + return { + getClientSettings: vi.fn(() => ({ + agentStatusHooksEnabled: enabled, + disabledTuiAgents + })) + } as unknown as OrcaRuntimeService +} + +describe('agent hook RPC methods', () => { + beforeEach(() => { + installForRuntimeHomeSerializedMock.mockReset() + realpathMock.mockReset() + realpathMock.mockImplementation(async (path: string) => path) + managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes() + recordManagedWslCodexHome('Ubuntu-24.04', RUNTIME_HOME) + }) + + it('installs the pane-selected WSL home once and returns its status', async () => { + const status = { agent: 'codex', state: 'installed' } + installForRuntimeHomeSerializedMock.mockResolvedValue(status) + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: LINUX_HOME, + orcaCodexHome: LINUX_HOME, + wslDistro: 'Ubuntu-24.04' + }) + + await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBe(status) + expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledExactlyOnceWith(RUNTIME_HOME, { + runtime: 'wsl', + wslDistro: 'Ubuntu-24.04' + }) + }) + + it.each([ + [false, []], + [true, ['codex']] + ])('does not install when hooks are disabled (%s, %j)', async (enabled, disabledTuiAgents) => { + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu-24.04' + }) + + await expect( + method.handler(params, { runtime: runtimeWithSettings(enabled, disabledTuiAgents) }) + ).resolves.toBeNull() + expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled() + }) + + it.each(['runtime', 'mobile'] as const)('rejects non-local %s callers', async (clientKind) => { + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu-24.04' + }) + + await expect( + method.handler(params, { + runtime: runtimeWithSettings(), + clientKind + } as RpcContext) + ).rejects.toThrow(/only available to the local Orca CLI/) + expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled() + }) + + it('propagates an attempted installer failure', async () => { + installForRuntimeHomeSerializedMock.mockRejectedValue(new Error('install failed')) + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu-24.04' + }) + + await expect(method.handler(params, { runtime: runtimeWithSettings() })).rejects.toThrow( + 'install failed' + ) + expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledOnce() + }) + + it('rejects a managed-looking home that resolves through a symlink', async () => { + realpathMock.mockResolvedValue( + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\outside-managed-home' + ) + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: LINUX_HOME, + orcaCodexHome: LINUX_HOME, + wslDistro: 'Ubuntu-24.04' + }) + + await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBeNull() + expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled() + }) + + it('rejects malformed distro names at the RPC schema', () => { + const method = prepareMethod() + + expect(() => + method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu\\..\\host' + }) + ).toThrow() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts new file mode 100644 index 00000000000..b26b117bd32 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight' +import { defineMethod, type RpcMethod } from '../core' + +const PrepareCodexForWslPaneParams = z + .object({ + codexHome: z.string().max(4_096), + orcaCodexHome: z.string().max(4_096), + wslDistro: z + .string() + .trim() + .min(1) + .max(255) + .regex(/^[^\\/\r\n]+$/) + }) + .strict() + +export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [ + defineMethod({ + name: 'agentHooks.prepareCodexForWslPane', + params: PrepareCodexForWslPaneParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Codex hook preparation is only available to the local Orca CLI.') + } + const settings = runtime.getClientSettings() + return await prepareManagedWslCodexHomeBeforeShellLaunch({ + env: { + CODEX_HOME: params.codexHome, + ORCA_CODEX_HOME: params.orcaCodexHome, + WSL_DISTRO_NAME: params.wslDistro + }, + hooksEnabled: + settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex') + }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 94b5f236562..d919a62c289 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -43,12 +43,14 @@ import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { ARTIFACT_METHODS } from './artifacts' +import { AGENT_HOOK_METHODS } from './agent-hooks' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when // auditing the security boundary or wiring new CLI commands. export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATUS_METHODS, + ...AGENT_HOOK_METHODS, ...AI_VAULT_METHODS, ...ARTIFACT_METHODS, ...AUTOMATION_METHODS, From 61c8f85d52df27503ca7add3a248537190ec0e19 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:28:52 -0700 Subject: [PATCH 029/118] Always enable Tasks button and provider shortcuts (#16838) * Always enable Tasks button and provider shortcuts Allows the task page to show an empty state explanation when no git repos are available, instead of disabling the button entirely. * Make task provider shortcuts keyboard-accessible sibling buttons Convert task provider shortcuts from non-semantic spans to proper button elements and position them as siblings of the Tasks button rather than children. This keeps them in the keyboard tab order while using opacity instead of display for visibility, ensuring they remain discoverable by keyboard navigation alongside the main Tasks button. --- .../components/sidebar/SidebarNav.test.tsx | 29 +++--- .../sidebar/SidebarTaskNavButton.tsx | 92 ++++++++----------- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index ac4208547a7..19c93a2d1c4 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -464,22 +464,27 @@ describe('SidebarNav', () => { expect(searchButton?.querySelector('kbd')).toBeNull() }) - it('hides task source shortcuts until the Tasks row is hovered or focused', async () => { + it('keeps task source shortcuts keyboard-reachable and revealed on Tasks row hover or focus', async () => { const container = await renderSidebarNav() const tasksButton = getButtonByText(container, 'Tasks') - const shortcuts = tasksButton.querySelector('[aria-label="Open GitHub tasks"]')?.parentElement + const githubShortcut = tasksButton.parentElement?.querySelector( + 'button[aria-label="Open GitHub tasks"]' + ) + expect(githubShortcut).not.toBeNull() + expect(githubShortcut?.tabIndex).toBe(0) + expect(tasksButton.contains(githubShortcut ?? null)).toBe(false) - expect(shortcuts?.className).toContain('hidden') - expect(shortcuts?.className).toContain('group-hover:flex') - expect(shortcuts?.className).toContain('group-focus-within:flex') + const shortcuts = githubShortcut?.parentElement + expect(shortcuts?.className).toContain('can-hover:opacity-0') + expect(shortcuts?.className).toContain('can-hover:group-hover:opacity-100') + expect(shortcuts?.className).toContain('can-hover:group-focus-within:opacity-100') }) it('hides available Tasks from its sidebar context menu', async () => { const container = await renderSidebarNav() const tasksButton = getButtonByText(container, 'Tasks') - expect(tasksButton.getAttribute('aria-disabled')).toBe('false') const tasksMenu = tasksButton.closest('[data-testid="context-menu"]') expect(tasksMenu).not.toBeNull() @@ -488,18 +493,20 @@ describe('SidebarNav', () => { expect(mocks.updateSettings).toHaveBeenCalledWith({ showTasksButton: false }) }) - it('keeps unavailable Tasks context-menu-capable while left click remains inert', async () => { + it('keeps Tasks enabled with no git repos so the page can explain the empty state', async () => { setSidebarState({ repos: [folderRepo()] }) const container = await renderSidebarNav() const tasksButton = getButtonByText(container, 'Tasks') - expect(tasksButton.getAttribute('aria-disabled')).toBe('true') + expect(tasksButton.getAttribute('aria-disabled')).toBeNull() expect(tasksButton.disabled).toBe(false) - expect(tasksButton.querySelectorAll('[role="button"]')).toHaveLength(0) - expect(tasksButton.querySelector('[aria-label="Open GitHub tasks"]')).toBeNull() + expect(tasksButton.className).not.toContain('opacity-50') + expect( + tasksButton.parentElement?.querySelector('button[aria-label="Open GitHub tasks"]') + ).not.toBeNull() await clickButton(tasksButton) - expect(mocks.openTaskPage).not.toHaveBeenCalled() + expect(mocks.openTaskPage).toHaveBeenCalled() const tasksMenu = tasksButton.closest('[data-testid="context-menu"]') expect(tasksMenu).not.toBeNull() diff --git a/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx b/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx index 7a29ba2eb12..426d80de67f 100644 --- a/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx +++ b/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx @@ -34,36 +34,23 @@ function HideTaskSidebarMenu({ onHide }: { onHide: () => void }): React.JSX.Elem } function TaskProviderShortcut({ - canBrowseTasks, label, onOpen, children }: { - canBrowseTasks: boolean label: string onOpen: () => void children: React.ReactNode }): React.JSX.Element { return ( - { - e.stopPropagation() - if (!canBrowseTasks) { - return - } - onOpen() - }} - className={cn( - 'rounded p-0.5 text-muted-foreground/70', - canBrowseTasks ? 'transition-colors hover:text-foreground' : 'cursor-default' - )} - aria-label={canBrowseTasks ? label : undefined} - aria-hidden={canBrowseTasks ? undefined : true} + ) } @@ -73,7 +60,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { const activeView = useAppStore((s) => s.activeView) const repos = useAppStore((s) => s.repos) const repoMap = useRepoMap() - const canBrowseTasks = repos.some((repo) => isGitRepoKind(repo)) const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false) const rawVisibleTaskProviders = useAppStore((s) => s.settings?.visibleTaskProviders) const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github') @@ -134,7 +120,7 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { ]) const handlePrefetch = React.useCallback(() => { - if (!canBrowseTasks || resolvedDefaultTaskSource !== 'github') { + if (resolvedDefaultTaskSource !== 'github') { return } const activeRepo = activeRepoId ? (repoMap.get(activeRepoId) ?? null) : null @@ -150,7 +136,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { } }, [ activeRepoId, - canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repoMap, @@ -171,38 +156,36 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { return ( - + {visibleTaskProviders.includes('github') ? ( ) : null} - + From 3179a5761f3262e676197086067d6b6e894e0d45 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:32:09 -0700 Subject: [PATCH 030/118] Allow select-all shortcuts in quick command dialogs (#16851) Add isSelectAllShortcut() utility to detect Cmd+A (Mac) or Ctrl+A (Linux/Windows). Use it to preserve native select-all behavior in editable fields instead of being intercepted by dialog keyboard handlers. --- ...tab-bar-quick-command-search-input.test.ts | 34 +++++++++++--- .../use-tab-bar-quick-command-search-input.ts | 5 +++ .../TerminalQuickCommandDialog.test.tsx | 44 ++++++++++++++++++- .../TerminalQuickCommandDialog.tsx | 11 +++++ src/renderer/src/lib/editable-target.ts | 14 ++++++ 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts index 25058e3f44a..1883cadde2c 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts @@ -30,18 +30,23 @@ function keyEvent(init: { key: string keyCode: number ctrlKey?: boolean + metaKey?: boolean + shiftKey?: boolean + altKey?: boolean isComposing?: boolean + preventDefault?: () => void + stopPropagation?: () => void }): never { return { key: init.key, keyCode: init.keyCode, ctrlKey: init.ctrlKey ?? false, - altKey: false, - metaKey: false, - shiftKey: false, + altKey: init.altKey ?? false, + metaKey: init.metaKey ?? false, + shiftKey: init.shiftKey ?? false, nativeEvent: { isComposing: init.isComposing ?? false }, - preventDefault: () => {}, - stopPropagation: () => {} + preventDefault: init.preventDefault ?? (() => {}), + stopPropagation: init.stopPropagation ?? (() => {}) } as never } @@ -67,6 +72,25 @@ describe('useTabBarQuickCommandSearchInput IME Enter ownership', () => { expect(onCommandValueChange).toHaveBeenCalledWith(entries[1].key) }) + it('lets the native select-all behavior run in the search input', () => { + const { result } = setup() + const stopPropagation = vi.fn() + const preventDefault = vi.fn() + + result.result.current.onKeyDown( + keyEvent({ + key: 'a', + keyCode: 65, + ctrlKey: true, + stopPropagation, + preventDefault + }) + ) + + expect(stopPropagation).toHaveBeenCalledOnce() + expect(preventDefault).not.toHaveBeenCalled() + }) + it('does not run the command on the bare redispatch after a confirm', () => { const { onRun, result } = setup() result.result.current.onCompositionStart() diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts index cd10dab214a..e0beabace4f 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts @@ -1,6 +1,7 @@ import { useCallback, type KeyboardEvent, type RefObject } from 'react' import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' +import { isSelectAllShortcut } from '@/lib/editable-target' type SearchInputOptions = { commandListRef: RefObject @@ -33,6 +34,10 @@ export function useTabBarQuickCommandSearchInput({ if (imeEnter.ownsKeyDown(event)) { return } + if (isSelectAllShortcut(event)) { + event.stopPropagation() + return + } if (event.key === 'Enter' && selectedCommand) { event.preventDefault() event.stopPropagation() diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx index e25152d0c32..a23a3367b12 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx @@ -2,7 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog' @@ -43,6 +43,10 @@ function findAnimatedRowContaining(text: string): HTMLElement { } describe('TerminalQuickCommandDialog animation structure', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + }) + afterEach(async () => { await act(async () => { for (const root of mountedRoots.splice(0)) { @@ -50,6 +54,44 @@ describe('TerminalQuickCommandDialog animation structure', () => { } }) document.body.innerHTML = '' + vi.unstubAllGlobals() + }) + + it('selects all text in editable fields with Cmd+A', async () => { + await renderDialog({ + id: 'qc-select-all', + label: 'Start dev server', + action: 'terminal-command', + command: 'npm run dev', + appendEnter: true, + scope: { type: 'global' } + }) + + const fields = [ + document.body.querySelector('input'), + document.body.querySelector('textarea[aria-label="Command"]') + ] + + for (const field of fields) { + expect(field).not.toBeNull() + if (!field) { + continue + } + field.setSelectionRange(field.value.length, field.value.length) + await act(async () => { + field.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'a', + code: 'KeyA', + metaKey: true, + bubbles: true, + cancelable: true + }) + ) + }) + + expect([field.selectionStart, field.selectionEnd]).toEqual([0, field.value.length]) + } }) it('keeps agent-only fields mounted as collapsed animated rows in terminal mode', async () => { diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index 784469d779a..c0d5a26c2e1 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -22,6 +22,7 @@ import { import { Label } from '@/components/ui/label' import { getAgentCatalog } from '@/lib/agent-catalog' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' +import { isSelectAllShortcut } from '@/lib/editable-target' import { TerminalQuickCommandActionToggle } from './TerminalQuickCommandActionToggle' import { TerminalQuickCommandAdvancedSection } from './TerminalQuickCommandAdvancedSection' import { TerminalQuickCommandContentSection } from './TerminalQuickCommandContentSection' @@ -196,6 +197,16 @@ export function TerminalQuickCommandDialog({
{ + if ( + isSelectAllShortcut(event) && + (event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement) + ) { + event.preventDefault() + event.stopPropagation() + event.target.select() + return + } if (isScreenSubmitShortcut(event) && canSave) { event.preventDefault() saveDraft() diff --git a/src/renderer/src/lib/editable-target.ts b/src/renderer/src/lib/editable-target.ts index b55a1e1a54a..24e4eeaf11b 100644 --- a/src/renderer/src/lib/editable-target.ts +++ b/src/renderer/src/lib/editable-target.ts @@ -1,3 +1,5 @@ +import { getShortcutPlatform } from './shortcut-platform' + // Why: shared across global keyboard listeners (App-level shortcuts and the // onboarding flow) so an in-progress text edit never gets hijacked by a // capture-phase keydown handler. @@ -21,3 +23,15 @@ export function isEditableTarget(target: EventTarget | null): boolean { null ) } + +export function isSelectAllShortcut( + event: Pick +): boolean { + if (event.key.toLowerCase() !== 'a' || event.altKey || event.shiftKey) { + return false + } + const platform = getShortcutPlatform() + return platform === 'darwin' + ? Boolean(event.metaKey) && !event.ctrlKey + : Boolean(event.ctrlKey) && !event.metaKey +} From 58232cd7974620dbe7347f8a93291e8d5a0ad937 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:41:01 -0700 Subject: [PATCH 031/118] Allow drag to reorder tabs on floating workspace (#16828) * Enable drag-to-reorder for floating workspace tabs - Wrap tab bar with FloatingWorkspaceTabDragContext to reuse workspace tab-drag-split logic and gesture model - Disable sensors while panel is closed to avoid DndContext conflicts with main workspace - Extract isFloatingTerminalDragTarget to separate module for clarity - Add tests for reorder behavior and titlebar drag-target detection * Add client-hosted row support to floating panel drag-to-reorder - Include `[data-client-hosted-browser-row-id]` in no-drag selectors alongside other tab types - Broaden type check from HTMLElement to Element for better SVG support - Add test coverage for client-hosted rows and SVG icon interactions * Improve floating terminal drag target detection with Element safety chec Handle undefined Element in non-DOM contexts by explicitly checking for its existence before type-checking. Clarify the logic by replacing double negatives with explicit null comparison, making the intent clearer and the code more resilient. --- ...oatingTerminalPanel.tab-lifecycle.test.tsx | 26 +++ .../FloatingTerminalPanel.tsx | 12 +- .../FloatingWorkspaceTabDragContext.tsx | 49 +++++ .../floating-terminal-titlebar-drag-target.ts | 13 ++ .../floating-workspace-tab-reorder.test.ts | 204 ++++++++++++++++++ 5 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts create mode 100644 src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx index 2bb123cc19f..b67dba62e06 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx @@ -499,3 +499,29 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.closeTab).not.toHaveBeenCalledWith('tab-c') }) }) + +describe('FloatingTerminalPanel tab drag wiring', () => { + beforeEach(setupFloatingTerminalPanelTest) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('hosts the tab strip in a drag context so floating tabs can be reordered', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + + const element = await renderPanel(true) + const dragContext = findByTypeName(element, 'FloatingWorkspaceTabDragContext') + + expect(dragContext.props.enabled).toBe(true) + expect(findByTypeName(dragContext.props.children, 'TabBar')).toBeDefined() + }) + + it('leaves the drag context inactive while the closed panel stays mounted', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + const element = await renderPanel(false) + + expect(findByTypeName(element, 'FloatingWorkspaceTabDragContext').props.enabled).toBe(false) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index ca6b25f6e07..94bdc23151a 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -90,6 +90,8 @@ import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' import { FloatingBrowserSlot } from './FloatingBrowserSlot' +import { FloatingWorkspaceTabDragContext } from './FloatingWorkspaceTabDragContext' +import { isFloatingTerminalDragTarget } from './floating-terminal-titlebar-drag-target' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles' import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls' @@ -153,8 +155,6 @@ type FloatingPanelShortcutResolution = | { kind: 'index'; index: number } | { kind: 'chrome'; action: KeybindingActionId } -const FLOATING_TERMINAL_NO_DRAG_SELECTOR = - 'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]' const FLOATING_TERMINAL_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]' type FloatingTerminalPanelBoundsState = { @@ -163,10 +163,6 @@ type FloatingTerminalPanelBoundsState = { source: FloatingTerminalPanelBoundsSource } -function isFloatingTerminalDragTarget(target: EventTarget): boolean { - return !(target instanceof HTMLElement && target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR)) -} - function readInitialPanelBounds(): FloatingTerminalPanelBoundsState { const defaultCommittedBounds = getDefaultFloatingTerminalCommittedBounds() const defaultRenderedBounds = getDefaultFloatingTerminalBounds() @@ -1825,7 +1821,7 @@ export function FloatingTerminalPanel({ onPointerCancel={handleDragEnd} onDoubleClick={handleTitlebarDoubleClick} > -
+ -
+ +}): React.JSX.Element { + const dragSplit = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled }) + + return ( + + +
+ {/* Why cloneElement over a render prop: the tab bar stays a plain child element, which keeps the panel's tree shallow-walkable. */} + {cloneElement(children, { hoveredTabInsertion: dragSplit.hoveredTabInsertion })} +
+ {/* Why: the source tab stays anchored in the overflow-hidden strip; this ghost is what follows the cursor. */} + + {dragSplit.activeDrag ? : null} + +
+
+ ) +} diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts b/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts new file mode 100644 index 00000000000..371dea745d5 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts @@ -0,0 +1,13 @@ +/** Interactive chrome living inside the draggable titlebar. `[data-tab-id]` and the + * client-hosted row marker cover every tab kind: a press + * on a tab starts a dnd-kit reorder, so it must never also move the panel. */ +const FLOATING_TERMINAL_NO_DRAG_SELECTOR = + 'button,input,textarea,select,[role="menuitem"],[data-tab-id],[data-client-hosted-browser-row-id],[data-floating-terminal-no-drag]' + +export function isFloatingTerminalDragTarget(target: EventTarget): boolean { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return true + } + + return target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR) === null +} diff --git a/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts b/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts new file mode 100644 index 00000000000..f52e08fdfbc --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts @@ -0,0 +1,204 @@ +/** + * @vitest-environment happy-dom + */ +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import { useAppStore } from '../../store' +import { useTabDragSplit, type TabDragItemData } from '../tab-group/useTabDragSplit' +import { isFloatingTerminalDragTarget } from './floating-terminal-titlebar-drag-target' + +vi.mock('../browser-pane/host-guest/webview-registry', () => ({ + acquireWebviewsDragPassthrough: vi.fn(() => vi.fn()) +})) + +vi.mock('../../runtime/web-runtime-session', () => ({ + isWebRuntimeSessionActive: vi.fn(() => false), + moveWebRuntimeSessionTab: vi.fn() +})) + +const GROUP_ID = 'floating-group' +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +function makeTab(id: string, sortOrder: number): Tab { + return { + id, + groupId: GROUP_ID, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal', + entityId: `term-${id}`, + label: id, + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + } +} + +function makeDragData(unifiedTabId: string): TabDragItemData { + return { + kind: 'tab', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: GROUP_ID, + unifiedTabId, + visibleTabId: `term-${unifiedTabId}`, + tabType: 'terminal', + label: unifiedTabId + } +} + +function makeDropEvent( + activeData: TabDragItemData, + overData: TabDragItemData | null, + pointerX: number +) { + return { + active: { data: { current: activeData }, rect: { current: { initial: null } } }, + over: overData + ? { + id: overData.visibleTabId, + data: { current: overData }, + // Tabs sit in a 100px-wide strip slot starting at x=200; midpoint is 250. + rect: { left: 200, top: 0, width: 100, height: 32 } + } + : null, + delta: { x: 0, y: 0 }, + activatorEvent: { clientX: pointerX, clientY: 16 } + } +} + +function renderFloatingDragHook(): ReturnType { + let result: ReturnType | null = null + function Probe(): null { + result = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled: true }) + return null + } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => root.render(createElement(Probe))) + mounted.push({ container, root }) + if (!result) { + throw new Error('useTabDragSplit did not render') + } + return result +} + +function dropTab(from: string, over: string, pointerX: number): void { + const drag = renderFloatingDragHook() + const activeData = makeDragData(from) + act(() => { + drag.onDragStart( + makeDropEvent(activeData, null, pointerX) as unknown as Parameters[0] + ) + }) + act(() => { + drag.onDragEnd( + makeDropEvent(activeData, makeDragData(over), pointerX) as unknown as Parameters< + typeof drag.onDragEnd + >[0] + ) + }) +} + +function floatingTabOrder(): string[] { + const groups: TabGroup[] = + useAppStore.getState().groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + return groups.find((group) => group.id === GROUP_ID)?.tabOrder ?? [] +} + +beforeEach(() => { + useAppStore.setState({ + // The floating workspace is never the active worktree; its tabs still reorder. + activeWorktreeId: 'wt-main', + activeGroupIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: GROUP_ID }, + groupsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: GROUP_ID, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: 'tab-a', + tabOrder: ['tab-a', 'tab-b', 'tab-c'] + } + ] + }, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + makeTab('tab-a', 0), + makeTab('tab-b', 1), + makeTab('tab-c', 2) + ] + }, + layoutByWorktree: {} + }) +}) + +afterEach(() => { + for (const { container, root } of mounted.splice(0)) { + act(() => root.unmount()) + container.remove() + } + document.body.replaceChildren() + vi.clearAllMocks() +}) + +describe('floating workspace tab reorder', () => { + it('moves a tab after the hovered tab when released on its right half', () => { + dropTab('tab-a', 'tab-c', 290) + expect(floatingTabOrder()).toEqual(['tab-b', 'tab-c', 'tab-a']) + }) + + it('moves a tab before the hovered tab when released on its left half', () => { + dropTab('tab-c', 'tab-a', 210) + expect(floatingTabOrder()).toEqual(['tab-c', 'tab-a', 'tab-b']) + }) + + it('leaves the order alone when a tab is dropped on itself', () => { + dropTab('tab-b', 'tab-b', 290) + expect(floatingTabOrder()).toEqual(['tab-a', 'tab-b', 'tab-c']) + }) + + it('republishes sortOrder so the panel renders the committed order', () => { + dropTab('tab-a', 'tab-c', 290) + const tabs = useAppStore.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + expect(tabs.map((tab) => [tab.id, tab.sortOrder])).toEqual([ + ['tab-a', 2], + ['tab-b', 0], + ['tab-c', 1] + ]) + }) +}) + +describe('floating panel titlebar drag target', () => { + it.each(['sortable-tab', 'browser-tab', 'editor-tab', 'client-hosted-browser-row-id'])( + 'does not move the panel when the press lands on a %s', + (kind) => { + const tab = document.createElement('div') + tab.dataset.tabId = kind + const label = document.createElement('span') + tab.appendChild(label) + document.body.appendChild(tab) + + expect(isFloatingTerminalDragTarget(tab)).toBe(false) + expect(isFloatingTerminalDragTarget(label)).toBe(false) + } + ) + + it('does not move the panel when a tab SVG icon receives the press', () => { + const tab = document.createElement('div') + tab.dataset.tabId = 'terminal-tab' + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + tab.appendChild(icon) + document.body.appendChild(tab) + + expect(isFloatingTerminalDragTarget(icon)).toBe(false) + }) + + it('still moves the panel from bare titlebar chrome', () => { + const titlebar = document.createElement('div') + document.body.appendChild(titlebar) + expect(isFloatingTerminalDragTarget(titlebar)).toBe(true) + }) +}) From f400f8fd5f35f0f27ad4697af8789f99fd9a4fa4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:44:03 -0700 Subject: [PATCH 032/118] fix(macos): opt out of press-and-hold so held keys repeat (#14746) (#15589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macos): opt out of press-and-hold so held keys repeat (#14746) macOS routes press-and-hold to the accent picker unless an app sets ApplePressAndHoldEnabled=false for its own bundle, so holding j in vim inserted one character instead of repeating. Orca never set it. Written at most once, and never over an explicit value: `defaults read` is domain-scoped and exits 1 when the key is absent, which is the only way to tell "unset" from a deliberate false — Electron's systemPreferences.getUserDefault reports false for both. A recorded decision in userData keeps a later launch from re-clobbering a user who deletes the key to get the accent picker back. * docs(macos): record the revert hazard and CI's macOS test gap Two things a reader of this module cannot otherwise know. A revert leaves the key written in every user's domain forever. AppKit reads the plist, not this file, so removing the code alone keeps press-and-hold disabled for everyone who ran an affected build. The sibling period-substitution module carries the same warning because that fix was already lost once this way. And the real-binary test file that pins the defaults(1) exit-code semantics this design rests on never runs in CI: the e2e workflow and both unit-test jobs are ubuntu and windows, and the only macOS runners in the repo are build and packaging jobs that run no tests. Those six tests plus the real-bundle e2e case pass on a developer Mac and execute zero times in a green PR, so the comment should not imply enforcement that is not there. Refs #14746 * feat(macos): let users turn the accent menu back on (#14746) Orca disables press-and-hold for its own preferences domain so held keys repeat. That is the right default, but the way back was a `defaults write` buried in a source comment: nothing in docs/ or the README mentioned it, and the preference is per-application, so it silently takes the accent picker away from the Markdown editor and every other text field too. Terminal -> Advanced now carries a "Character Accent Menu" switch, macOS and desktop only. A web client cannot write a macOS preference for the machine the user is looking at, so the control and its search-index entry are both gated on that, not on the client's platform alone. Precedence, which is the part that is easy to get wrong: the setting is `undefined` until the user touches it, which is what keeps a hand-run `defaults write` in charge for everyone who never opens the toggle. Once used, Orca owns the key and writes exactly what the switch asks for -- `ApplePressAndHoldEnabled` *is* the accent-menu switch, so it maps straight through with no inversion. The choice is compared against `appliedSetting` in the existing decision record rather than against the domain, so a `defaults write` made *after* using the toggle is still the newer choice and survives the next launch. Re-asserting the value every launch would have reintroduced the clobbering the record exists to prevent. The write lands for the next launch, since AppKit reads the preference as the process starts, so the toggle shows the same restart banner the window-blur setting uses. That banner is now a shared component, keeping its original translation keys. docs/reference/macos-press-and-hold.md records the precedence rules, the `defaults read` rationale, the revert hazard, and the fact that none of this executes in CI: every macOS job builds or packages and runs no tests, so the real-binary and e2e coverage here passes only on a developer Mac. * docs(macos): stop asserting when AppKit re-reads the press-and-hold key Five places stated "AppKit reads the preference as the process starts" as fact. That is the reason given for requiring a relaunch, and it is not something this change ever measured. Evidence points the other way: terminal emulators that register this key after their process has started get key repeat in that same launch, which a read-once-at-startup model cannot explain. The relaunch requirement itself still looks right, but for a different and verifiable reason: the write goes out through a separate `defaults` process, so this app's own cached copy need not observe it. That is what the comments now say, with the AppKit question left open rather than answered. Refs #14746 * docs(macos): correct the startup comment's launch-timing claim The comment said this call site is "the last point that can still matter for this launch", which contradicts the rest of the module: the write is assumed to land for the next launch because it goes out through a separate `defaults` process. Reported on the PR by @innocarpe, who also supplied the replacement wording. Co-authored-by: innocarpe * refactor(macos): probe press-and-hold through the shared spawn chokepoint `src/shared/child-process/child-process-import-boundary.test.ts` forbids a direct `node:child_process` import outside its allowlist, and the allowlist only shrinks — so this module moves to `runProcessSync`, which exists for callers that genuinely cannot await. This one runs before `app.whenReady()`. `runProcessSync` returns a non-zero exit instead of throwing it, so the three-way read decision is re-expressed against `ProcessResult`: exit 0 is an explicit value, exit 1 is a missing key, and a timeout, a signal kill, any other exit, or a child that never started all stay 'unknown'. The throw path is now inside `interpretDefaultsRead` so a spawn failure is reachable from a test rather than hidden in an untested catch, and the write checks the exit code — a refused `defaults write` no longer looks like success. Both boundary-test failures were the same import: with it gone the offender count returns to 155, so no ratchet baseline is bumped. * Revert "feat(macos): let users turn the accent menu back on (#14746)" This reverts commit cc5669f3068fd736a1fcf6017f1ea36b18f9ee04. --------- Co-authored-by: innocarpe --- .gitignore | 1 + docs/reference/macos-press-and-hold.md | 58 ++++ src/main/index.ts | 4 + ...s-and-hold-default.defaults-domain.test.ts | 173 +++++++++++ src/main/macos-press-and-hold-default.test.ts | 286 ++++++++++++++++++ src/main/macos-press-and-hold-default.ts | 257 ++++++++++++++++ .../e2e/macos-press-and-hold-startup.spec.ts | 96 ++++++ 7 files changed, 875 insertions(+) create mode 100644 docs/reference/macos-press-and-hold.md create mode 100644 src/main/macos-press-and-hold-default.defaults-domain.test.ts create mode 100644 src/main/macos-press-and-hold-default.test.ts create mode 100644 src/main/macos-press-and-hold-default.ts create mode 100644 tests/e2e/macos-press-and-hold-startup.spec.ts diff --git a/.gitignore b/.gitignore index a08e1058eeb..176206bacfb 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ docs/** !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md +!docs/reference/macos-press-and-hold.md !docs/reference/orcad-operations.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/windows-process-enumeration.md diff --git a/docs/reference/macos-press-and-hold.md b/docs/reference/macos-press-and-hold.md new file mode 100644 index 00000000000..946a1ff7125 --- /dev/null +++ b/docs/reference/macos-press-and-hold.md @@ -0,0 +1,58 @@ +# macOS press-and-hold and key repeat + +macOS opens the accent picker when a key is held unless an application opts out in its preferences +domain. That prevents held keys from repeating in terminal applications such as vim. On the first +eligible launch, Orca writes: + +```sh +defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false +``` + +The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and +non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect +on the next launch. + +## Precedence and decision record + +Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a +user choice and preserved. Only an unset key receives the `false` default. + +The decision is stored once in +`/macos-press-and-hold-default.json`. An `applied` or +`kept-user-preference` decision prevents future launches from touching the domain again. +Probe and write failures remain retryable so a transient failure does not permanently disable the +fix. + +`defaults read ` is used instead of +`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an +explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures, +timeouts, and other exit statuses leave the preference alone. + +## Restoring the accent picker + +Set the preference explicitly, then restart Orca: + +```sh +defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true +``` + +After Orca has recorded its one-time decision, deleting the key also restores the macOS default +without Orca recreating it: + +```sh +defaults delete com.stablyai.orca ApplePressAndHoldEnabled +``` + +Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that +domain instead when applicable. + +## Reverting + +Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert +must also arrange to delete the key for users who ran an affected build. + +## Test coverage + +Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership, +record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins +the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS. diff --git a/src/main/index.ts b/src/main/index.ts index a142fc6cf7d..c21adc53642 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import { type Tray, session } from 'electron' +import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default' import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' import { electronApp, is } from '@electron-toolkit/utils' import { @@ -938,6 +939,9 @@ if (hasSingleInstanceLock) { installDevParentSignalQuit(shouldCoupleToDevParent) // Why: run after configureDevUserDataPath but before app.setName('Orca') (whenReady), which changes the resolved path on case-sensitive filesystems. initDataPath() + // Why here: initDataPath above gives the canonical userData path for the record file; the write + // itself lands for the next launch (see macos-press-and-hold-default.ts). + applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. initSessionParseCachePersistence({ filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), diff --git a/src/main/macos-press-and-hold-default.defaults-domain.test.ts b/src/main/macos-press-and-hold-default.defaults-domain.test.ts new file mode 100644 index 00000000000..18dab052450 --- /dev/null +++ b/src/main/macos-press-and-hold-default.defaults-domain.test.ts @@ -0,0 +1,173 @@ +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcessSync } from '../shared/child-process/run-process' +import { + PRESS_AND_HOLD_KEY, + ensureMacPressAndHoldDefault, + interpretDefaultsRead, + readDomainPressAndHoldPreference, + writeDomainPressAndHoldPreference, + type PressAndHoldHost, + type PressAndHoldRecord +} from './macos-press-and-hold-default' + +/** + * Runs against the real `/usr/bin/defaults` on a throwaway Orca-owned domain. + * + * The whole design rests on one claim the mocks cannot make: a domain that has never been written + * is distinguishable from one explicitly set to `false`. Electron's `systemPreferences` cannot tell + * them apart, so if `defaults` could not either, "only write when unset" would be unimplementable. + * + * Every domain used here is a throwaway UUID under Orca's own prefix, deleted along with its plist + * in `afterEach`; the real `com.stablyai.orca` domain is never read or written. + */ + +// Why a real Orca-owned domain shape: the ownership guard rejects anything else, so a fake prefix +// would exercise a different branch than production. +const domains: string[] = [] + +/** Pinned against the real binary below, then reused by the platform-agnostic unit tests. */ +const DEFAULTS_MISSING_EXIT_CODE = 1 + +function throwawayDomain(): string { + const domain = `com.stablyai.orca.defaults-domain-test.${randomUUID()}` + domains.push(domain) + return domain +} + +function write(domain: string, value: 'true' | 'false'): void { + execFileSync('/usr/bin/defaults', ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value], { + stdio: 'ignore' + }) +} + +function rawRead(domain: string): string { + return execFileSync('/usr/bin/defaults', ['read', domain, PRESS_AND_HOLD_KEY], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() +} + +function hostFor(domain: string, record: PressAndHoldRecord | null = null): PressAndHoldHost { + return { + platform: 'darwin', + resolveBundleIdentifier: () => domain, + readRecord: () => record, + writeRecord: () => undefined, + readDomainPreference: readDomainPressAndHoldPreference, + writeDomainPreference: writeDomainPressAndHoldPreference, + now: () => new Date().toISOString() + } +} + +describe.skipIf(process.platform !== 'darwin')( + 'press-and-hold against the real defaults binary', + () => { + afterEach(() => { + for (const domain of domains.splice(0)) { + try { + execFileSync('/usr/bin/defaults', ['delete', domain], { stdio: 'ignore' }) + } catch { + // The domain may never have been created; nothing to clean up. + } + // Why the file too: `defaults delete` empties the domain but leaves its plist behind, so + // without this every run litters the developer's ~/Library/Preferences. + rmSync(join(homedir(), 'Library', 'Preferences', `${domain}.plist`), { force: true }) + } + }) + + it('tells an unset key apart from an explicit false', () => { + const unset = throwawayDomain() + const explicitlyFalse = throwawayDomain() + write(explicitlyFalse, 'false') + + expect(readDomainPressAndHoldPreference(unset)).toBe('unset') + expect(readDomainPressAndHoldPreference(explicitlyFalse)).toBe('set') + expect(rawRead(explicitlyFalse)).toBe('0') + }) + + it('reports an explicit true as set', () => { + const domain = throwawayDomain() + write(domain, 'true') + + expect(readDomainPressAndHoldPreference(domain)).toBe('set') + expect(rawRead(domain)).toBe('1') + }) + + it('writes false into a previously unset domain', () => { + const domain = throwawayDomain() + + expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('applied') + expect(rawRead(domain)).toBe('0') + }) + + it('leaves an explicit value untouched, in either direction', () => { + for (const value of ['true', 'false'] as const) { + const domain = throwawayDomain() + write(domain, value) + + expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('kept-user-preference') + expect(rawRead(domain)).toBe(value === 'true' ? '1' : '0') + } + }) + + it('refuses to call a failed probe "unset"', () => { + // Real runs, not hand-built results: a binary that is not there must come back 'unknown' + // through the same path production takes, not be mistaken for "the key does not exist". + const spawnFailure = (): ReturnType => + runProcessSync({ + program: '/usr/bin/defaults-does-not-exist', + args: ['read'], + stdio: ['ignore', 'ignore', 'ignore'] + }) + expect(spawnFailure).toThrow() + expect(interpretDefaultsRead(spawnFailure)).toBe('unknown') + + const missingKey = runProcessSync({ + program: '/usr/bin/defaults', + args: ['read', throwawayDomain(), PRESS_AND_HOLD_KEY], + stdio: ['ignore', 'ignore', 'ignore'] + }) + // The one exit code the whole design reads as unset; every other outcome is 'unknown'. + expect(missingKey.code).toBe(DEFAULTS_MISSING_EXIT_CODE) + expect(missingKey.timedOut).toBe(false) + expect(interpretDefaultsRead(() => missingKey)).toBe('unset') + }) + + // Skipped as root, where the read-only directory below would still be writable. + it.skipIf(process.getuid?.() === 0)('reports a write the binary refused', () => { + // Why a real refusal: `defaults write` exits non-zero instead of throwing, so a caller that + // only caught exceptions would record 'applied' for a value that never reached the plist. + const readOnly = join(mkdtempSync(join(tmpdir(), 'orca-press-hold-ro-')), 'locked') + mkdirSync(readOnly) + chmodSync(readOnly, 0o500) + try { + expect(writeDomainPressAndHoldPreference(join(readOnly, 'domain'), false)).toBe(false) + } finally { + chmodSync(readOnly, 0o700) + rmSync(readOnly, { recursive: true, force: true }) + } + }) + + it('sees the key disappear again after the user deletes it', () => { + const domain = throwawayDomain() + write(domain, 'false') + execFileSync('/usr/bin/defaults', ['delete', domain, PRESS_AND_HOLD_KEY], { stdio: 'ignore' }) + + expect(readDomainPressAndHoldPreference(domain)).toBe('unset') + // A launch that already decided must not rewrite it, which is what makes the delete stick. + const decided: PressAndHoldRecord = { + version: 1, + decision: 'applied', + domain, + decidedAt: '2026-01-01T00:00:00.000Z' + } + expect(ensureMacPressAndHoldDefault(hostFor(domain, decided))).toBe('already-decided') + expect(readDomainPressAndHoldPreference(domain)).toBe('unset') + }) + } +) diff --git a/src/main/macos-press-and-hold-default.test.ts b/src/main/macos-press-and-hold-default.test.ts new file mode 100644 index 00000000000..c30aed82d10 --- /dev/null +++ b/src/main/macos-press-and-hold-default.test.ts @@ -0,0 +1,286 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult } from '../shared/child-process/run-process' +import { + ensureMacPressAndHoldDefault, + interpretDefaultsRead, + isOrcaPreferencesDomain, + readBundleIdentifierFromExecutablePath, + type PressAndHoldDecision, + type PressAndHoldHost, + type PressAndHoldRecord +} from './macos-press-and-hold-default' + +const ORCA_DOMAIN = 'com.stablyai.orca' + +type HostOverrides = Partial & { record?: PressAndHoldRecord | null } + +/** A macOS host whose domain has no explicit value — the state every affected user is in. */ +function createHost(overrides: HostOverrides = {}): { + host: PressAndHoldHost + writes: { domain: string; value: boolean }[] + records: PressAndHoldRecord[] +} { + const writes: { domain: string; value: boolean }[] = [] + const records: PressAndHoldRecord[] = [] + let stored = overrides.record ?? null + const host: PressAndHoldHost = { + platform: 'darwin', + resolveBundleIdentifier: () => ORCA_DOMAIN, + readRecord: () => stored, + writeRecord: (record) => { + stored = record + records.push(record) + }, + readDomainPreference: () => 'unset', + writeDomainPreference: (domain, value) => { + writes.push({ domain, value }) + return true + }, + now: () => '2026-08-20T00:00:00.000Z', + ...overrides + } + return { host, writes, records } +} + +function terminalRecord(decision: PressAndHoldDecision): PressAndHoldRecord { + return { version: 1, decision, domain: ORCA_DOMAIN, decidedAt: '2026-01-01T00:00:00.000Z' } +} + +describe('ensureMacPressAndHoldDefault', () => { + it('writes the opt-out when the domain has no explicit value', () => { + const { host, writes, records } = createHost() + + expect(ensureMacPressAndHoldDefault(host)).toBe('applied') + expect(writes).toEqual([{ domain: ORCA_DOMAIN, value: false }]) + expect(records.at(-1)?.decision).toBe('applied') + }) + + describe('platform guard', () => { + for (const platform of ['win32', 'linux'] as const) { + it(`does nothing at all on ${platform}`, () => { + const probe = vi.fn(() => 'unset' as const) + const readRecord = vi.fn(() => null) + const { host, writes, records } = createHost({ + platform, + readDomainPreference: probe, + readRecord + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('not-macos') + // Why assert the reads too: "inert" means no `defaults` subprocess and no userData I/O, + // not merely no write. + expect(probe).not.toHaveBeenCalled() + expect(readRecord).not.toHaveBeenCalled() + expect(writes).toEqual([]) + expect(records).toEqual([]) + }) + } + }) + + describe('respects an explicit user setting', () => { + it('leaves the domain alone when the user already set a value', () => { + // One arm covers both `true` and `false`: the probe deliberately reports only presence, so + // whichever way the user's value points it is theirs. That the probe really does report + // presence for both is pinned against the real binary in the .defaults-domain test. + const { host, writes, records } = createHost({ readDomainPreference: () => 'set' }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('kept-user-preference') + expect(writes).toEqual([]) + expect(records.at(-1)?.decision).toBe('kept-user-preference') + }) + + it('never re-applies after a launch already decided', () => { + for (const decision of ['applied', 'kept-user-preference'] as const) { + const probe = vi.fn(() => 'unset' as const) + const { host, writes } = createHost({ + record: terminalRecord(decision), + readDomainPreference: probe + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided') + expect(probe).not.toHaveBeenCalled() + expect(writes).toEqual([]) + } + }) + + it('stays out of the way after the user deletes the key we wrote', () => { + // The documented way back to the accent picker is `defaults delete`, which returns the + // domain to "unset". Without the recorded decision the next launch would rewrite it. + const { host, writes } = createHost({ + record: terminalRecord('applied'), + readDomainPreference: () => 'unset' + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided') + expect(writes).toEqual([]) + }) + }) + + describe('domains we do not own', () => { + it('skips a bare Electron bundle rather than writing into a shared domain', () => { + const probe = vi.fn(() => 'unset' as const) + const { host, writes, records } = createHost({ + resolveBundleIdentifier: () => 'com.github.Electron', + readDomainPreference: probe + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle') + expect(probe).not.toHaveBeenCalled() + expect(writes).toEqual([]) + expect(records.at(-1)?.domain).toBe('com.github.Electron') + }) + + it('skips when the bundle identifier cannot be resolved', () => { + const { host, writes } = createHost({ resolveBundleIdentifier: () => null }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle') + expect(writes).toEqual([]) + }) + + it('accepts Orca and its channel-scoped bundles, and nothing else', () => { + expect(isOrcaPreferencesDomain('com.stablyai.orca')).toBe(true) + expect(isOrcaPreferencesDomain('com.stablyai.orca.dev')).toBe(true) + expect(isOrcaPreferencesDomain('com.github.Electron')).toBe(false) + // Why: a prefix test without the dot would accept a lookalike bundle id. + expect(isOrcaPreferencesDomain('com.stablyai.orcafake')).toBe(false) + }) + }) + + describe('failures leave the decision open', () => { + it('does not write when the probe cannot tell unset from user-set', () => { + const { host, writes, records } = createHost({ readDomainPreference: () => 'unknown' }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('probe-failed') + expect(writes).toEqual([]) + expect(records.at(-1)?.decision).toBe('probe-failed') + }) + + it('retries on the next launch after a failed write', () => { + const { host, records } = createHost({ writeDomainPreference: () => false }) + expect(ensureMacPressAndHoldDefault(host)).toBe('write-failed') + + const retry = createHost({ record: records.at(-1) }) + expect(ensureMacPressAndHoldDefault(retry.host)).toBe('applied') + expect(retry.writes).toEqual([{ domain: ORCA_DOMAIN, value: false }]) + }) + }) + + it('does not rewrite an unchanged non-terminal record', () => { + const first = createHost({ resolveBundleIdentifier: () => 'com.github.Electron' }) + expect(ensureMacPressAndHoldDefault(first.host)).toBe('foreign-bundle') + + const second = createHost({ + record: first.records.at(-1), + resolveBundleIdentifier: () => 'com.github.Electron' + }) + expect(ensureMacPressAndHoldDefault(second.host)).toBe('foreign-bundle') + expect(second.records).toEqual([]) + }) +}) + +/** + * Why here and not only in the .defaults-domain file: that file is macOS-only and CI has no macOS + * runner, so this three-way rule would otherwise be enforced nowhere in CI. These cases need no + * `defaults` binary — the sibling file pins that the real binary produces them. + */ +describe('interpretDefaultsRead', () => { + function probe(overrides: Partial): () => ProcessResult { + return () => ({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides }) + } + + it('reads a clean exit as an explicit value', () => { + expect(interpretDefaultsRead(probe({ code: 0 }))).toBe('set') + }) + + it('reads only "does not exist" as unset', () => { + expect(interpretDefaultsRead(probe({ code: 1 }))).toBe('unset') + }) + + describe('refuses to call a failed probe unset', () => { + // Each of these would otherwise overwrite a value the user deliberately set. + const failures: [string, () => ProcessResult][] = [ + ['killed before it exited', probe({ code: null, signal: 'SIGTERM' })], + ['timed out', probe({ code: null, timedOut: true })], + [ + 'timed out after somehow reporting the missing-key code', + probe({ code: 1, timedOut: true }) + ], + ['exited with some other error', probe({ code: 2 })], + ['exited with a negative status', probe({ code: -1 })], + [ + 'never started at all', + () => { + throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) + } + ] + ] + for (const [name, failing] of failures) { + it(name, () => { + expect(interpretDefaultsRead(failing)).toBe('unknown') + }) + } + }) +}) + +describe('readBundleIdentifierFromExecutablePath', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + function bundleWithPlist(body: string): string { + const root = mkdtempSync(join(tmpdir(), 'orca-press-hold-')) + roots.push(root) + mkdirSync(join(root, 'Orca.app', 'Contents', 'MacOS'), { recursive: true }) + writeFileSync(join(root, 'Orca.app', 'Contents', 'Info.plist'), body) + return join(root, 'Orca.app', 'Contents', 'MacOS', 'Orca') + } + + it('reads CFBundleIdentifier from the plist beside the executable', () => { + const exe = bundleWithPlist( + '\nCFBundleName\nOrca\n' + + 'CFBundleIdentifier\n\tcom.stablyai.orca\n' + ) + + expect(readBundleIdentifierFromExecutablePath(exe)).toBe('com.stablyai.orca') + }) + + it('returns null when the plist is missing or carries no identifier', () => { + expect(readBundleIdentifierFromExecutablePath('/nonexistent/App.app/Contents/MacOS/App')).toBe( + null + ) + expect(readBundleIdentifierFromExecutablePath(bundleWithPlist(''))).toBe( + null + ) + expect( + readBundleIdentifierFromExecutablePath( + bundleWithPlist('CFBundleIdentifier') + ) + ).toBe(null) + }) +}) + +describe('startup wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + it('runs before app.whenReady(), which is the last point AppKit could still see it', () => { + const callIndex = source.indexOf( + 'applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())' + ) + const initDataPathIndex = source.indexOf('initDataPath()') + const readyIndex = source.indexOf('app.whenReady().then(') + + expect(callIndex).toBeGreaterThanOrEqual(0) + expect(readyIndex).toBeGreaterThanOrEqual(0) + // Why after initDataPath: the record lives beside orca-data.json, and the canonical userData + // path is only captured there. + expect(callIndex).toBeGreaterThan(initDataPathIndex) + expect(callIndex).toBeLessThan(readyIndex) + }) +}) diff --git a/src/main/macos-press-and-hold-default.ts b/src/main/macos-press-and-hold-default.ts new file mode 100644 index 00000000000..41b7819f2b2 --- /dev/null +++ b/src/main/macos-press-and-hold-default.ts @@ -0,0 +1,257 @@ +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { runProcessSync, type ProcessResult } from '../shared/child-process/run-process' +import { writeFileAtomically } from './codex-accounts/fs-utils' + +/** + * Turns off the macOS accent picker for Orca's own preferences domain (#14746). + * + * macOS routes press-and-hold to the accent popup unless an app opts out via + * `ApplePressAndHoldEnabled`, so holding `j` in vim inserts one character instead of repeating. + * The key is unset by default, which is why every terminal-hosting Mac app ships this opt-out. + * + * Written once and never again: a user who wants the accent picker back sets + * `defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true` (or deletes the key), and + * the recorded decision below keeps a later launch from overwriting that choice. + * + * A fresh write is assumed to land for the *next* launch, not the current one: it goes out through + * a separate `defaults` process, so this app's own cached copy may not observe it. Whether AppKit + * re-reads sooner is untested, so the conservative assumption is the one encoded here. + * + * If this is ever reverted, delete the key as well — AppKit reads the plist, not this file, so + * removing the code alone leaves press-and-hold disabled forever for everyone who ran an affected + * build, with nothing left in the tree to explain it. + * + * The `defaults`-binary behaviour this relies on is pinned by the sibling test file, which runs + * only on a developer Mac: CI has no macOS test runner, so those tests never execute there. + */ + +export const PRESS_AND_HOLD_KEY = 'ApplePressAndHoldEnabled' +export const PRESS_AND_HOLD_RECORD_FILE = 'macos-press-and-hold-default.json' +export const PRESS_AND_HOLD_RECORD_VERSION = 1 + +/** Why not `systemPreferences.getUserDefault`: it reads through the whole NSUserDefaults search + * list and is typed non-nullable, so an unset key and an explicit `false` both come back `false` — + * and the system default is unset, so it reports `false` on a Mac where press-and-hold is on. + * `defaults read ` is domain-scoped and exits 1 when the key is absent. */ +const DEFAULTS_BINARY = '/usr/bin/defaults' +/** A hang cutoff, not a latency budget: the probe measures ~6ms, and cutting a slow-but-working + * `defaults` short costs the user the feature for that launch. The shared 30s default is what + * must not apply — this runs before `ready`, so a 30s block is a hung app. */ +const DEFAULTS_TIMEOUT_MS = 5_000 +/** Why: `defaults` exits 1 for "does not exist"; anything else means the probe itself failed. */ +const DEFAULTS_MISSING_STATUS = 1 + +const ORCA_BUNDLE_ID = 'com.stablyai.orca' + +export type PressAndHoldDecision = + /** Not macOS — nothing is read or written. */ + | 'not-macos' + /** A previous launch already decided; the domain is never touched again. */ + | 'already-decided' + /** The running bundle is not Orca's (e.g. a bare `Electron.app`), whose domain we do not own. */ + | 'foreign-bundle' + /** `defaults read` could not answer, so we cannot tell an unset key from a user's choice. */ + | 'probe-failed' + /** `defaults write` failed. */ + | 'write-failed' + /** The domain already carries an explicit value; the user's choice stands. */ + | 'kept-user-preference' + /** The key was unset and we wrote `false`. */ + | 'applied' + +/** Decisions about the domain itself. Anything else is a condition that can change by next launch. */ +const TERMINAL_DECISIONS = new Set(['applied', 'kept-user-preference']) + +export type PressAndHoldRecord = { + version: number + decision: PressAndHoldDecision + domain: string | null + decidedAt: string +} + +export type PressAndHoldHost = { + platform: NodeJS.Platform + resolveBundleIdentifier: () => string | null + readRecord: () => PressAndHoldRecord | null + writeRecord: (record: PressAndHoldRecord) => void + readDomainPreference: (domain: string) => 'set' | 'unset' | 'unknown' + writeDomainPreference: (domain: string, value: boolean) => boolean + now: () => string +} + +/** Only Orca's own bundle: an unpackaged run is `com.github.Electron`, shared with every other + * unpackaged Electron app on the machine. */ +export function isOrcaPreferencesDomain(domain: string): boolean { + return domain === ORCA_BUNDLE_ID || domain.startsWith(`${ORCA_BUNDLE_ID}.`) +} + +/** `/Contents/MacOS/` → `/Contents/Info.plist`. */ +export function readBundleIdentifierFromExecutablePath(execPath: string): string | null { + try { + const plist = readFileSync(join(dirname(dirname(execPath)), 'Info.plist'), 'utf8') + const match = /CFBundleIdentifier<\/key>\s*([^<]*)<\/string>/.exec(plist) + const identifier = match?.[1]?.trim() + return identifier ? identifier : null + } catch { + return null + } +} + +/** + * Why this distinction carries the whole design: only a real "does not exist" answer may be read as + * unset. A killed or timed-out probe never answered, and calling that unset would let a broken + * probe overwrite a value the user chose. + */ +export function interpretDefaultsRead(probe: () => ProcessResult): 'set' | 'unset' | 'unknown' { + let result: ProcessResult + try { + result = probe() + } catch { + // `runProcessSync` throws only when the child never started, which answers nothing at all. + return 'unknown' + } + // Why before the code: a timeout kills mid-run, so any exit it leaves behind is not an answer. + if (result.timedOut) { + return 'unknown' + } + if (result.code === 0) { + return 'set' + } + return result.code === DEFAULTS_MISSING_STATUS ? 'unset' : 'unknown' +} + +export function readDomainPressAndHoldPreference(domain: string): 'set' | 'unset' | 'unknown' { + // Why no captured output: the exit code is the whole answer, and reading only presence is what + // makes an explicit `true` and an explicit `false` both count as the user's. + return interpretDefaultsRead(() => + runProcessSync({ + program: DEFAULTS_BINARY, + args: ['read', domain, PRESS_AND_HOLD_KEY], + timeoutMs: DEFAULTS_TIMEOUT_MS, + stdio: ['ignore', 'ignore', 'ignore'] + }) + ) +} + +export function writeDomainPressAndHoldPreference(domain: string, value: boolean): boolean { + try { + // Why the code check: a non-zero `defaults write` returns rather than throwing, so treating a + // completed run as success would record 'applied' for a write that never landed. + return ( + runProcessSync({ + program: DEFAULTS_BINARY, + args: ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value ? 'true' : 'false'], + timeoutMs: DEFAULTS_TIMEOUT_MS, + stdio: ['ignore', 'ignore', 'ignore'] + }).code === 0 + ) + } catch { + return false + } +} + +export function pressAndHoldRecordPath(userDataPath: string): string { + return join(userDataPath, PRESS_AND_HOLD_RECORD_FILE) +} + +function parseRecord(raw: string): PressAndHoldRecord | null { + const parsed = JSON.parse(raw) as Partial + if (parsed.version !== PRESS_AND_HOLD_RECORD_VERSION || typeof parsed.decision !== 'string') { + return null + } + return { + version: PRESS_AND_HOLD_RECORD_VERSION, + decision: parsed.decision as PressAndHoldDecision, + domain: typeof parsed.domain === 'string' ? parsed.domain : null, + decidedAt: typeof parsed.decidedAt === 'string' ? parsed.decidedAt : '' + } +} + +/** + * Apply Orca's press-and-hold default at most once, leaving any explicit user value alone. + * + * Returns the decision so startup can log it; the same value is persisted for support triage. + */ +export function ensureMacPressAndHoldDefault(host: PressAndHoldHost): PressAndHoldDecision { + // Why first and without touching disk: Windows and Linux must do no work at all here. + if (host.platform !== 'darwin') { + return 'not-macos' + } + + const previous = host.readRecord() + if (previous && TERMINAL_DECISIONS.has(previous.decision)) { + return 'already-decided' + } + + const record = (decision: PressAndHoldDecision, domain: string | null): PressAndHoldDecision => { + // Why skip an unchanged rewrite: the non-terminal decisions are re-evaluated every launch. + if (!previous || previous.decision !== decision || previous.domain !== domain) { + host.writeRecord({ + version: PRESS_AND_HOLD_RECORD_VERSION, + decision, + domain, + decidedAt: host.now() + }) + } + return decision + } + + const domain = host.resolveBundleIdentifier() + if (!domain || !isOrcaPreferencesDomain(domain)) { + return record('foreign-bundle', domain) + } + + const existing = host.readDomainPreference(domain) + if (existing === 'unknown') { + return record('probe-failed', domain) + } + if (existing === 'set') { + return record('kept-user-preference', domain) + } + if (!host.writeDomainPreference(domain, false)) { + return record('write-failed', domain) + } + return record('applied', domain) +} + +/** Why log: silently rewriting a macOS default for the user's login account should leave a trace, + * and the failure paths are otherwise invisible. The quiet decisions are the steady state. */ +const REPORTED_DECISIONS: Partial> = { + applied: `set ${PRESS_AND_HOLD_KEY}=false so held keys repeat; takes effect next launch`, + 'probe-failed': `could not read ${PRESS_AND_HOLD_KEY}; leaving it alone`, + 'write-failed': `could not write ${PRESS_AND_HOLD_KEY}; held keys will not repeat` +} + +/** Wires {@link ensureMacPressAndHoldDefault} to the real bundle, `defaults`, and userData. */ +export function applyMacPressAndHoldDefaultAtStartup(userDataPath: string): PressAndHoldDecision { + const recordPath = pressAndHoldRecordPath(userDataPath) + const decision = ensureMacPressAndHoldDefault({ + platform: process.platform, + resolveBundleIdentifier: () => readBundleIdentifierFromExecutablePath(process.execPath), + readRecord: () => { + try { + return parseRecord(readFileSync(recordPath, 'utf8')) + } catch { + return null + } + }, + writeRecord: (record) => { + try { + // Why: this runs before `ready`, and Electron has not necessarily created userData yet. + mkdirSync(userDataPath, { recursive: true }) + writeFileAtomically(recordPath, `${JSON.stringify(record, null, 2)}\n`) + } catch { + // Best-effort: an unwritten record only costs one repeated probe next launch. + } + }, + readDomainPreference: readDomainPressAndHoldPreference, + writeDomainPreference: writeDomainPressAndHoldPreference, + now: () => new Date().toISOString() + }) + const reported = REPORTED_DECISIONS[decision] + if (reported) { + console.log(`[press-and-hold] ${reported}`) + } + return decision +} diff --git a/tests/e2e/macos-press-and-hold-startup.spec.ts b/tests/e2e/macos-press-and-hold-startup.spec.ts new file mode 100644 index 00000000000..5eb0fba388d --- /dev/null +++ b/tests/e2e/macos-press-and-hold-startup.spec.ts @@ -0,0 +1,96 @@ +/** + * Startup wiring for the macOS press-and-hold opt-out (#14746). + * + * What this can prove: the routine runs during startup, resolves the *real* running bundle's + * identifier from its Info.plist, records a decision, and stays completely inert off macOS. + * + * What it cannot prove: that held keys repeat. Key repeat versus the accent picker is decided by + * AppKit from a preference this process does not re-read after writing, so observing it needs a relaunch and a + * physically held key — neither of which Playwright's synthesized CDP key events go through. + * The preference semantics the fix depends on are pinned against the real `/usr/bin/defaults` in + * src/main/macos-press-and-hold-default.defaults-domain.test.ts. + * + * The E2E app runs unpackaged, so its bundle identifier is Electron's own rather than Orca's. That + * is the ownership guard's case, and asserting it here is the point: `defaults` resolves the user's + * real home regardless of the harness's HOME isolation, so a run that wrote anything would be + * writing into a domain shared with every other unpackaged Electron app on the machine. + */ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' + +test.use({ seedTestRepo: false }) + +type StartupState = { + recordRaw: string | null + bundleIdentifier: string | null + execPath: string +} + +async function readPressAndHoldStartupState( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(({ app }) => { + const nodeFs = process.getBuiltinModule('node:fs') + const nodePath = process.getBuiltinModule('node:path') + const recordPath = nodePath.join(app.getPath('userData'), 'macos-press-and-hold-default.json') + const read = (file: string): string | null => { + try { + return nodeFs.readFileSync(file, 'utf8') + } catch { + return null + } + } + // Why resolved here rather than hardcoded: this is the independent half of the cross-check — + // the spec derives the identifier from the launched bundle without reusing product code. + const plist = read( + nodePath.join(nodePath.dirname(nodePath.dirname(process.execPath)), 'Info.plist') + ) + const match = plist + ? /CFBundleIdentifier<\/key>\s*([^<]*)<\/string>/.exec(plist) + : null + return { + recordRaw: read(recordPath), + bundleIdentifier: match ? match[1].trim() : null, + execPath: process.execPath + } + }) +} + +test.describe('macOS press-and-hold default', () => { + test.skip(process.platform !== 'darwin', 'macOS-only startup routine') + + test('records a decision for the bundle it is actually running from', async ({ electronApp }) => { + const state = await readPressAndHoldStartupState(electronApp) + + expect(state.recordRaw, 'startup must leave a decision record').not.toBeNull() + const record = JSON.parse(state.recordRaw!) as { + version: number + decision: string + domain: string | null + } + + expect(record.version).toBe(1) + expect(state.bundleIdentifier).not.toBeNull() + expect(record.domain).toBe(state.bundleIdentifier) + + const ownsDomain = + record.domain === 'com.stablyai.orca' || record.domain!.startsWith('com.stablyai.orca.') + if (ownsDomain) { + // A packaged or dev-identity bundle: the write path is live and must have settled. + expect(['applied', 'kept-user-preference']).toContain(record.decision) + } else { + // The unpackaged harness. Anything but a refusal means Orca wrote into a foreign domain. + expect(record.decision).toBe('foreign-bundle') + } + }) +}) + +test.describe('off macOS', () => { + test.skip(process.platform === 'darwin', 'covers the non-macOS branch') + + test('writes nothing at all', async ({ electronApp }) => { + const state = await readPressAndHoldStartupState(electronApp) + + expect(state.recordRaw).toBeNull() + }) +}) From 808fc3563dbfb5d4a369d1d874cf2c94e86236bb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:27:52 +0000 Subject: [PATCH 033/118] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index d604b83bf97..49201fa15cc 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 30m + + downloads: 31m @@ -15,7 +15,7 @@ downloads downloads - 30m - 30m + 31m + 31m From 249d93bc5d2fd3b04581aae9916afc84bc787c8b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:34:21 -0700 Subject: [PATCH 034/118] feat(browser-preview): render remote HTML docs locally over an orca-preview scheme (STA-5557) (#16679) --- config/reliability-gates.jsonc | 615 ++++++++++++--- electron.vite.config.ts | 1 + .../browser-manager-annotation-bridge.test.ts | 210 +++++ .../browser-manager-guest-lifecycle.test.ts | 98 +++ ...owser-manager-guest-policy-profile.test.ts | 250 ++++++ .../browser-manager-viewport-override.test.ts | 29 - src/main/browser/browser-manager.ts | 106 ++- ...browser-session-partition-policies.test.ts | 185 +++++ .../browser-session-partition-policies.ts | 33 +- .../doc-preview-download-block-notice.test.ts | 107 +++ .../doc-preview-download-block-notice.ts | 34 + .../doc-preview-failure-notice.test.ts | 70 ++ .../browser/doc-preview-failure-notice.ts | 34 + .../browser/doc-preview-file-reader.test.ts | 333 ++++++++ src/main/browser/doc-preview-file-reader.ts | 198 +++++ .../doc-preview-grant-registry.test.ts | 164 ++++ .../browser/doc-preview-grant-registry.ts | 208 +++++ .../browser/doc-preview-guest-policy.test.ts | 550 +++++++++++++ src/main/browser/doc-preview-guest-policy.ts | 247 ++++++ src/main/browser/doc-preview-protocol.test.ts | 336 ++++++++ src/main/browser/doc-preview-protocol.ts | 144 ++++ ...ffscreen-browser-backend-lifecycle.test.ts | 85 +- src/main/browser/offscreen-browser-backend.ts | 44 +- src/main/index.ts | 11 + src/main/ipc/browser-grab-ipc.ts | 19 + src/main/ipc/browser-guest-view-ipc.ts | 26 +- ...browser-preview-tool-authorization.test.ts | 510 ++++++++++++ .../ipc/browser-tab-registration-wait.test.ts | 108 +++ src/main/ipc/browser-tab-registration-wait.ts | 13 + src/main/ipc/browser.test.ts | 54 +- src/main/ipc/browser.ts | 8 + src/main/ipc/doc-preview-grant-ipc.test.ts | 159 ++++ src/main/ipc/doc-preview-grant-ipc.ts | 95 +++ .../main-window-webview-security.test.ts | 261 ++++++- .../window/main-window-webview-security.ts | 61 +- src/preload/api-types.ts | 2 + src/preload/api/doc-preview-api.ts | 29 + .../doc-preview-link-interception.test.ts | 269 +++++++ src/preload/doc-preview-link-interception.ts | 152 ++++ src/preload/doc-preview-link.ts | 15 + src/preload/index.ts | 27 + .../ClientHostedBrowserPagePane.tsx | 28 +- .../browser-guest-annotate-overlays.tsx | 189 +++++ .../annotate/browser-page-grab-action.ts | 8 +- .../guest-annotation-viewport-bridge.ts | 43 ++ .../use-browser-page-grab-annotations.ts | 16 +- .../assemble-chrome/BrowserAddressBar.tsx | 4 +- .../browser-chrome-address-slot.ts | 13 + .../browser-chrome-toolbar.tsx | 206 +++++ .../browser-navigation-control-row.test.tsx | 66 +- .../browser-navigation-control-row.tsx | 61 +- .../assemble-chrome/browser-page-toolbar.tsx | 282 ++----- .../browser-page-viewport-overlays.tsx | 163 +--- .../browser-reload-control.tsx | 95 +++ .../browser-workspace-pane.tsx | 49 +- .../use-browser-page-webview-lifecycle.ts | 29 +- .../host-guest/use-guest-drag-passthrough.ts | 32 + .../browser-address-bar-navigation.test.ts | 27 + .../browser-address-bar-navigation.ts | 24 +- .../remote-browser-page-toolbar.tsx | 24 +- .../use-remote-browser-page-navigation.ts | 2 +- .../HtmlDocPreview.failure-message.test.tsx | 342 ++++++++ .../HtmlDocPreview.toolbar.test.tsx | 582 ++++++++++++++ .../workspace-doc/HtmlDocPreview.tsx | 413 ++++++++++ .../doc-preview-document-actions.test.ts | 206 +++++ .../doc-preview-document-actions.ts | 97 +++ .../doc-preview-document-chip.tsx | 68 ++ .../doc-preview-document-identity.test.ts | 67 ++ .../doc-preview-document-identity.ts | 38 + .../doc-preview-failure-messages.ts | 70 ++ .../doc-preview-overflow-menu.tsx | 76 ++ .../workspace-doc/doc-preview-toolbar.tsx | 118 +++ .../doc-preview-webview-history.ts | 80 ++ .../use-doc-preview-guest-tools.test.ts | 134 ++++ .../use-doc-preview-guest-tools.ts | 103 +++ .../workspace-doc-page-pane.test.tsx | 109 +++ .../workspace-doc/workspace-doc-page-pane.tsx | 60 ++ .../editor/EditorPanelShell.header.test.tsx | 124 +++ .../components/editor/EditorPanelShell.tsx | 3 +- .../src/components/editor/editor-header.ts | 5 + .../tab-strip-pointer-activation.test.tsx | 38 + .../tab-bar/tab-strip-pointer-activation.ts | 20 +- .../TerminalLinkActionPopover.tsx | 6 +- .../terminal-file-link-actions.test.ts | 62 ++ .../terminal-file-link-actions.ts | 66 +- .../terminal-file-open-routing.ts | 48 +- ...minal-link-remote-runtime-ssh-open.test.ts | 64 +- .../terminal-remote-file-download-open.ts | 37 + .../ipc-events/browser-state-ipc-bridge.ts | 29 + .../doc-preview-external-link-bridge.test.ts | 92 +++ .../src/hooks/useIpcEvents-lifecycle.test.ts | 2 + src/renderer/src/i18n/locales/en.json | 39 +- src/renderer/src/i18n/locales/ko.json | 77 +- src/renderer/src/i18n/locales/zh.json | 85 +- .../lib/browser-page-palette-activation.ts | 21 +- .../lib/browser-workspace-tab-activation.ts | 30 + .../src/lib/doc-preview-grants.test.ts | 169 ++++ src/renderer/src/lib/doc-preview-grants.ts | 86 +++ .../src/lib/execution-host-display-label.ts | 61 ++ .../file-preview-action-visibility.test.tsx | 85 ++ .../file-preview-capability-cleanup.test.ts | 91 --- src/renderer/src/lib/file-preview.test.ts | 409 ++++++---- src/renderer/src/lib/file-preview.ts | 255 +++--- .../src/lib/workspace-file-host-routing.ts | 50 ++ ...ync-runtime-graph-editor-diff-tabs.test.ts | 180 +++++ .../src/runtime/sync-runtime-graph.ts | 18 +- .../browser-workspace-doc-location.test.ts | 412 ++++++++++ src/renderer/src/store/slices/browser.ts | 125 ++- .../src/store/slices/tabs-hydration.test.ts | 63 ++ .../src/store/slices/tabs-hydration.ts | 9 + src/shared/browser-page-doc-location.ts | 27 + src/shared/browser-workspace-types.ts | 17 + src/shared/doc-preview-scheme.test.ts | 45 ++ src/shared/doc-preview-scheme.ts | 105 +++ .../workspace-session-browser-schema.ts | 20 +- .../helpers/paired-client-window-reveal.ts | 31 + .../helpers/paired-html-preview-inventory.ts | 341 +++++--- .../paired-remote-html-browser-focus.spec.ts | 459 ----------- ...d-remote-html-preview-local-render.spec.ts | 731 ++++++++++++++++++ 119 files changed, 12735 insertions(+), 1726 deletions(-) create mode 100644 src/main/browser/browser-manager-annotation-bridge.test.ts create mode 100644 src/main/browser/browser-manager-guest-policy-profile.test.ts create mode 100644 src/main/browser/browser-session-partition-policies.test.ts create mode 100644 src/main/browser/doc-preview-download-block-notice.test.ts create mode 100644 src/main/browser/doc-preview-download-block-notice.ts create mode 100644 src/main/browser/doc-preview-failure-notice.test.ts create mode 100644 src/main/browser/doc-preview-failure-notice.ts create mode 100644 src/main/browser/doc-preview-file-reader.test.ts create mode 100644 src/main/browser/doc-preview-file-reader.ts create mode 100644 src/main/browser/doc-preview-grant-registry.test.ts create mode 100644 src/main/browser/doc-preview-grant-registry.ts create mode 100644 src/main/browser/doc-preview-guest-policy.test.ts create mode 100644 src/main/browser/doc-preview-guest-policy.ts create mode 100644 src/main/browser/doc-preview-protocol.test.ts create mode 100644 src/main/browser/doc-preview-protocol.ts create mode 100644 src/main/ipc/browser-preview-tool-authorization.test.ts create mode 100644 src/main/ipc/browser-tab-registration-wait.test.ts create mode 100644 src/main/ipc/doc-preview-grant-ipc.test.ts create mode 100644 src/main/ipc/doc-preview-grant-ipc.ts create mode 100644 src/preload/api/doc-preview-api.ts create mode 100644 src/preload/doc-preview-link-interception.test.ts create mode 100644 src/preload/doc-preview-link-interception.ts create mode 100644 src/preload/doc-preview-link.ts create mode 100644 src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx create mode 100644 src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-address-slot.ts create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx create mode 100644 src/renderer/src/components/browser-pane/host-guest/use-guest-drag-passthrough.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-failure-messages.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-history.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx create mode 100644 src/renderer/src/components/editor/EditorPanelShell.header.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-remote-file-download-open.ts create mode 100644 src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts create mode 100644 src/renderer/src/lib/browser-workspace-tab-activation.ts create mode 100644 src/renderer/src/lib/doc-preview-grants.test.ts create mode 100644 src/renderer/src/lib/doc-preview-grants.ts create mode 100644 src/renderer/src/lib/execution-host-display-label.ts create mode 100644 src/renderer/src/lib/file-preview-action-visibility.test.tsx delete mode 100644 src/renderer/src/lib/file-preview-capability-cleanup.test.ts create mode 100644 src/renderer/src/lib/workspace-file-host-routing.ts create mode 100644 src/renderer/src/store/slices/browser-workspace-doc-location.test.ts create mode 100644 src/shared/browser-page-doc-location.ts create mode 100644 src/shared/doc-preview-scheme.test.ts create mode 100644 src/shared/doc-preview-scheme.ts delete mode 100644 tests/e2e/paired-remote-html-browser-focus.spec.ts create mode 100644 tests/e2e/paired-remote-html-preview-local-render.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ac37e3760b9..68dce578bc0 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2817,16 +2817,16 @@ }, { "id": "browser-session.remote-html-preview-ownership", - "title": "Paired HTML previews keep one host page and one stable client split", + "title": "Paired HTML previews render locally as document browser tabs and create no host browser page", "maturity": "experimental", "protection": "partial", "owner": "browser-runtime", "layer": "renderer-paired-runtime-reconciliation", "surfaces": [ - "paired remote HTML file preview", - "background preview creation and explicit browser activation", - "client-only split placement", - "capability-unknown and mixed-version preview rejection cleanup", + "paired HTML file preview served locally over the orca-preview scheme", + "preview split placement that leaves editor focus alone", + "grant-scoped document reads, including truncation and out-of-worktree refusal", + "capability-unknown and mixed-version browser-creation rejection cleanup", "post-create reconciliation failure and compensating rollback", "remote browser close convergence" ], @@ -2834,8 +2834,9 @@ "providers": ["remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["remote-runtime"], - "coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, a forced post-create reconciliation timeout, and navigation held beyond the client create deadline. The same reconciliation fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, canonical preallocated page identity, navigation-independent acknowledgement, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.", + "coverageNotes": "A macOS desktop host and a separate paired Electron client exercise the Explorer preview action end to end: the document renders inside the client-local orca-preview guest, the client gains exactly one browser workspace and it is the document one, the host's own page registry and tab snapshot gain nothing, the split does not take focus from the source editor, an explicit click activates the preview, and closing it with its X leaves the editor and terminal intact. Both host oracles are read in a run where an ordinary URL browser tab from the same client does arrive in them, because a host that cannot project browser pages at all would otherwise answer the absence question the same way. A second journey quits that client with a document tab open and relaunches it on the same profile, which is the restore the user's original report turned on: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit. The same journey pins the guest URL to the orca-preview scheme, so a regression back to file:// or to a host browser page is visible. The rewritten journey caught a live gap the old one could not see: since previews became browser pages they had no chrome to hand focus to their guest, so main refused every reported link click as coming from an unfocused guest and the one route out of a preview was dead. Main's own log answered it — registered true, grant live, focused false — after the embedder reported the anchor really taking six real presses. The pane now hands focus over when it is the reader's surface, and the journey routes. The preview's own components moved out of the editor folder into browser-pane/workspace-doc/ with this change, since a preview is a browser pane and no longer an editor one; their i18n keys keep the historical auto.components.editor.HtmlDocPreview prefix rather than dropping thirty translated strings across four locales for a tidier key path. Seven gate commands named those files by their old paths and were re-issued at the new ones, and the nine dated runs behind them were retired rather than left naming files that are no longer there, on the same principle this gate already applied twice: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. Each retired record's finding is carried into the replacement run recorded below it, including the red half of the entry/subresource pair, which was re-measured by re-applying its mutant at the new path rather than being dropped. The fence over the page-management channels was decided channel by channel before it was built, and that decision record is: of the 23 channels that can name a page, 6 are allowed for a workspace-document page — the tool channels the reader drives, which reach it through the one authority that reads both halves of the registry — 9 refuse (download accept and cancel, devtools, certificate proceed, client page metadata publish, viewport override, WebAuthn account response, registration probe, registration repair), 3 cannot name a page at all because they take a profile or a worktree instead, and the rest are the registration and activation doors, refused on membership. What shipped is not the per-channel guard that classification implies: eleven call sites read the browsing map directly, so a guard per channel is exactly the caller-flag seam that has to be remembered at every future call site. The registry was split in two instead, which makes the classification structural — a document guest is absent from the browsing map, so every refusing channel refuses by absence rather than by a guard, and a channel added later inherits the refusal without being classified at all. Unit contracts cover grant scoping and path containment, per-owner read routing, refusal of a truncated or over-cap read, the old-server binary degradation, scheme admission on will-attach-webview, the guest navigation policy, the plan layer's local/SSH/paired/out-of-worktree routing, and the distinct sentence the reader sees for each failure reason. The paired browser-creation surfaces this gate still owns — capability rejection cleanup, canonical page identity, reconciliation rollback, close convergence — keep their existing headed and headless journeys. Linux and Windows live journeys remain gaps. A refused subresource is a notice on a rendered document rather than a failure panel, since only the entry document's failure leaves the reader with nothing; the entry document is served as text by every owner and cannot be refused for its format at all. Grants die with the renderer document that minted them, at window creation and on every reload, because the renderer holds the only record of which preview owns which grant. The mint and revoke channels answer only the trusted renderer, and a preview tab is never republished to mobile clients, which have no grant to read it with. External URLs leave the preview only via a trusted user click on an anchor while the preview is focused; script-driven navigation and popups reach nothing. The guest's navigation sinks are deny-only and route no URL at all, and the guest carries a preview-only preload, pinned by will-attach-webview and denied to every other attach path, whose capture-phase click handler reports an anchor's URL to main; main answers only a report from a preview guest with a live bound grant that is the focused contents, and only for http(s). Two earlier reader-facing records from 2026-08-26, which measured a shape where a subresource failure was filtered out and the panel still carried an unsupported-asset sentence, were retired with that shape rather than left contradicting the 10/10 runs of the command they share. Two more were retired on the same principle when the external-link design changed: a record showing that removing a recent-input gate let the unattended document open two browser tabs, and a record showing that dropping the webview's allowpopups attribute left a target=_blank press with nothing to route. Both measured machinery that no longer exists — the input gate could not tell a script's navigation from the click it followed, which is the case now recorded below, and the preload intercepts the press before Chromium considers a popup at all, so allowpopups was removed. WebRTC is fenced by the guest's IP-handling policy alone: this Chromium answers a webrtc content-security-policy directive as unrecognized and gathers candidates anyway, so the directive is deliberately absent from the served policy and a unit assertion keeps it out. A standalone Electron probe on the same version gathered 7 candidates including a STUN reflexive one without the policy and 0 with it. Two command paths this gate used to carry no longer exist, and their structured evidence was retired with them rather than left naming files that are no longer there: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. The first is src/renderer/src/lib/file-preview-capability-cleanup.test.ts, deleted in STA-5557 when HTML previews stopped creating paired browser tabs: on 2026-08-12 its five-assertion STA-4063 oracle ran red 5/5 on scan e4c278eb52 and on main bc2e30000b, partially green 2/5 on pre-fix PR #14100, and green 5/5 on candidate 14b5877a75; the surviving half of that oracle is web-runtime-browser-capability-cleanup.test.ts, still commanded above. The second is tests/e2e/paired-remote-html-browser-focus.spec.ts, renamed in STA-5557 to paired-remote-html-preview-local-render.spec.ts because the journey no longer proves anything about browser focus: its 2026-08-12 runs established the owning-runtime routing and no-focus-steal behavior of the then-current browser-tab preview (red on main 346e59c879 and a90a18d43d, green on candidates d05217c40d and b7de9c0961, red again with only the side-preview focus or routing seam disabled), and its 2026-08-26 runs are recorded below under the current path. An adversarial pass after the journey landed found four coverage gaps rather than shipping defects, and all four are pinned here. The page url had a third door: the navigation commit wrote whatever url was reported, unfenced, and while nothing reaches it for a document page today it sat one report away from giving a document page a URL that persistence, the publish boundary and the address bar all read. The group projection was the load-bearing half of the mobile publish boundary that no test had: removing it alone left the whole suite green, because no test in it had tab groups, while the phone payload gained a group whose order and active tab named a document tab it was never sent. The focus handoff was covered only in the narrowing direction, so widening it — dropping the page-activity check, or either half of the reader-surface check — survived both suites. And the handoff itself was offered once and never again, so another app taking the front left the preview's one route out shut until the pane remounted, which is a desktop gap rather than a test-only one.", "motivatingLinks": [ + "https://linear.app/stably/issue/STA-5557/remote-html-preview-render-workspace-files-locally-over-the-file", "https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server", "https://linear.app/stably/issue/STA-4025/p1-browser-create-reports-success-when-tab-reconcile-fails-after-pr", "https://linear.app/stably/issue/STA-4063/p1-paired-html-preview-rejects-outside-cleanup-after-pr-13909", @@ -2843,19 +2844,35 @@ "https://github.com/stablyai/orca/pull/14363", "https://github.com/stablyai/orca/pull/13876" ], - "invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.", - "oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Hold a real navigation response beyond the 15-second client deadline after host creation; require the first RPC to return the exact host inventory page ID, one host page, and no retry. Repeat reconciliation faults against headless serve. Retain the separate exact-page reconciliation rollback oracle.", + "invariant": "Opening one HTML preview from a paired client renders the workspace document in exactly one client-local browser tab, located by that document and served over the orca-preview scheme. The client gains exactly that one browser workspace and it is the document one — blank where a URL page carries a URL, named by the document, with the chip naming the file — while the host gains no browser page at all, neither in its own page registry nor in the tab snapshot its clients publish into. The preview occupies its own split without taking focus from the source editor; an explicit click activates it, and closing it removes only the preview. Following a document from a file link is the other half of that switch and does move the reader to it, tab group included, whether the preview is new or already open, because opening a file is a request to look at it. A document tab quit with the client comes back as the same row on a grant the relaunched client mints afresh. A preview is named by the browser page it is open in, not by a namespace of its own, and the page registry has two halves: a workspace-document guest is registered in its own map and is absent from the browsing one entirely. That absence is the fence. Page, session and profile management, agent tab enumeration and command targeting, download routing and certificate attribution all read the browsing map directly, in more places than a per-channel guard could be remembered in, so none of them can name a document page and none of them carries a guard. Browser tools the reader drives (element grab, hover describe, selection capture, the annotation viewport bridge) are the one operation that legitimately spans the halves, and they go through the single authority that reads both, keyed by the page and its hosting renderer. The halves are disjoint in both directions: browsing registration refuses a page the document half already holds, and minting a grant refuses a page the browsing half already holds, so one id can never name a surface in both. The headless backend acts on that refusal by destroying the window it had already opened rather than leaving a policy-less page behind an id nothing can drive, keeping nothing under that id for its own shutdown to hand back. Registration refuses on the same terms when the guest it was asked about is already gone. The exit door is guarded in both its halves: a preview withdraws by revoking its grant and never through the unregister channel, so a page the document half holds arriving there is refused before either the registration teardown or the grab-state disposal beside it, which would otherwise drop the intent an in-flight preview grab compares by identity and leave that grab answering ok without ever arming its guest. A bridge request whose guest does not resolve is refused without tearing down the page it named, so a misaddressed request cannot cancel a healthy page's in-flight downloads and grabs. The annotation viewport bridge resolves its guest when its serialized op actually runs rather than when the request arrived, so a cross-process navigation while it waited cannot leave the bridge installed in a retired guest while the reader looks at a new one. State main keys by a preview's page is disposed when that page's grant is revoked, which is the only signal a preview's surface is gone. A tool asking for a page whose guest has not attached yet waits for that registration and arms when it arrives, rather than answering not-ready at the reader; that wait resolves only the request already naming this page, never the worktree-wide or any-tab waits the CLI and agents use to ask for a browser tab to drive. Handing the previewed document to the reader's own machine routes on the owners its grant was minted against — the file's own connection owner and the worktree's own runtime owner, neither read from the tab's stored fields. Only a document proven to live on this machine reaches the client OS; one with a resolved remote owner is downloaded first; and one whose owner cannot be resolved at all, workspace root included, is refused with a message naming that, because the download route would otherwise read the same absolute path on the client and hand back a same-named local file under the remote document's name. A runtime-owned path that falls outside its worktree root is refused by that route itself and surfaces as a failure toast rather than a download. Nothing the document does writes a file to this machine either: the preview partition denies downloads outright instead of routing them through the browser download flow, which has no page to attribute a preview's bytes to and would otherwise reserve a name in this desktop's Downloads folder and write them there unprompted. That refusal is visible to the reader and invisible to the document: the preview's shell carries a fixed sentence saying downloads are off, published at most once per preview per interval so a document asking in a loop cannot fill Orca's chrome, while the page itself gets back exactly what it got before, which is nothing. The sentence names no file, because the document chooses the name it offers; and a refusal never takes the document away the way an entry document's own failure does, whatever it names. A preview is a browser tab, not an editor tab in a preview mode: it is named the way a browser tab is named — by the document it shows when that document declares a title, and by the file it shows when it does not — while the chip goes on naming the file and the host whatever the document calls itself. A title is refused on the same terms the url is: a document that declares none has Chromium report the grant URL as its title, and that title is stored, mirrored onto the tab and written to disk, so anything carrying the scheme falls back to the file instead. It is created by the preview action as a page located by its document, it carries the workspace-relative path copy the editor's path header owned, and closing it revokes the grant that made the document readable while a URL tab closing beside it revokes nothing. Chrome persisted by builds that made previews editor tabs is dropped on restore rather than coming back naming a surface no restore can produce, and the ordinary editor tab for the same document is left alone. A document tab is held back at the mobile publish boundary — no client holds its grant, and the wire has no tab kind for it — while an ordinary browser tab beside it still publishes. It is held back from the group projection that publishes tab order, recency and group activity as well as from the tab list itself, so no published group names a tab the phone is never sent. A browser page can be located by a workspace document instead of a URL, and the document is the whole of its stored identity. The grant and the orca-preview URL that document is served over are minted when the page mounts and replaced by a hard reload, so neither is ever written to the page's url, mirrored onto its tab, persisted or published: such a page's url is the blank URL from creation through restore, including when a session written elsewhere carries a grant URL in, and what the session carries is the worktree and path a restored page mints afresh against today's owners. Every door onto a page's url holds that line — creation, the title update, and the navigation commit alike — so a report about a document page cannot give it a URL it never had, and the title fallback and the loading affordance follow the url each door actually wrote. The mirror carries the document too, so a tab entry cannot go on naming a document its active page has left. Every guest in the app is policy-attached through one door: a workspace document takes a restricted profile there rather than a separate installer beside it, so the attachment bookkeeping that door owns — what registration refuses, and what teardown frees — covers a preview on the same terms as a browsing page, and a preview takes none of the browsing machinery that door installs. That authority answers from the moment the embedder hands the guest over rather than only after a later navigation: the guest binds to the grant it is already showing, so the tools reach the document the reader opened and not just one they navigated to. A read the host reports as truncated or over-cap is refused rather than served partially, and a document outside the paired worktree is refused with a message naming that boundary instead of a bare read failure. The rendered document reaches nothing off-machine on its own: every served response carries a self-only content security policy, the preview session cancels any request that is not in-document, subframes cannot navigate outside the grant, a guest no document has yet bound to a grant may not navigate at all, the guest gathers no ICE candidates, and an SSH path that canonicalizes outside the grant root is refused before it is read. The one route out is a link the reader presses: a trusted click on an anchor, reported by the preview's own preload from a guest that holds focus and is still bound to a live grant, leaves as an Orca browser tab rather than a native window or a dead click. The preview hands its guest that focus itself whenever it is the surface the reader is in — a browsing page gets it from the chrome around it, and a preview has no chrome to get it from — and it does so only then, so a preview mounted behind a terminal or an editor never takes the keyboard from what the reader is actually in. It offers again when the window itself takes focus back and nothing in the embedder has claimed that focus, because another app coming to the front lands focus on the embedder rather than the guest and the route out would otherwise stay shut until something remounted the pane — while the same window focus also arrives when the reader presses a tab, that being the guest's own blur returning, and taking focus back from there would fight the reader for their own click. Nothing else does. A navigation or popup the document starts by itself is swallowed whatever else is happening, including immediately after a genuine press elsewhere in the document, so a page that can read its grant cannot hand it to a browser tab; a middle click opens nothing; and a fragment link is answered inside the document. A preview attach carries the preview preload and no renderer-supplied one, and no other attach path can acquire it. A subresource the workspace will not send degrades the document to a notice naming that file, never to a failure panel over a page that rendered. A grant outlives neither the tab that owns it nor the renderer document that minted it, and only the trusted renderer can mint or revoke one. For the browser creations this gate still owns, owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback.", + "oracle": "In paired Electron, write an HTML fixture that declares its own title on the host, invoke the Explorer preview action on the client, and require the document text to be readable out of the orca-preview guest before judging any absence. With that presence established, require the client to hold exactly one browser workspace more than its baseline and that workspace to be the document one: page and tab url blank, the document path mirrored onto both, the tab named by the document's title, the chip naming the file, no editor row of the retired preview species anywhere, and the guest URL carrying the orca-preview scheme. Ask the host through its own page registry as well as through the tab snapshot, and in the same run open an ordinary URL browser tab from the same client and require that one to arrive in both — the presence precondition without which “the host gained nothing” is satisfied just as well by an oracle that cannot see browser pages at all. Require the preview to sit in a group other than the source editor's while the active group and tab remain the source editor's. Then click away to the terminal, click the preview tab, require it to reactivate and still render, close it with its own X, and require the document tab to be gone while the host still holds only the URL tab and the source group, source editor and terminal survive. Quit the client with a document tab open and relaunch it on the same profile: require the same workspace row to come back, blank and named by the document, rendering the document again over a grant URL that differs from the one that was quit, with no preview-scheme or document-named page anywhere in what the host holds. Prove the halves are live by flipping one product property at a time and requiring the run to fail: publish document workspaces to the host like ordinary ones, and stop mirroring the document onto the workspace row. Have the fixture document attempt its own egress on every load — an unattended window.open and location.href to an off-machine URL, plus an inline ICE gathering probe — and require the same baseline counts and a candidate count of zero, so the document's own attempts are measured rather than assumed. Then, as a separate phase after the close oracle has already run, bring the client window to the front, press the document's heading with a real mouse event, and require the document to report that the same press drove it to attempt a second window.open and location.href while both browser counts stay at that phase's baseline and nothing routes — the case a recent-input gate cannot distinguish from the press's own effect. Only then press the target=_blank link with a real mouse event and require both a recorded routing call that returned success and a browser count above that baseline, with the preview tab still open. Drive the preload's click policy as a unit oracle over a real document: a dispatched click, a trusted press on an external anchor, an anchor reached through what it wraps, an SVG animated href, a sibling preview link, fragment and percent-encoded fragment targets, a bare hash, and a middle click. Create a browser page located by a workspace document, handing creation a live grant URL, and require its stored url, its mirrored tab url and the written session payload all to be blank with no orca-preview string anywhere in what was written, while an ordinary page created the same way keeps the URL it was given and asks for the address bar the document page never does. Parse the written page and tab through the session schema and require the document to survive both halves. Hydrate them back and require the document page to return blank and still named — including when its page row was salvaged away and only the tab's own copy remains, and when a foreign session carried a grant URL into both rows. Drive the mirror across a page switch out of the document and back, and across a repair in which the document is the only mirrored field that differs. Name a document page from its document and require the tab to take that name, name it with an empty title and require the file, name it with a live grant URL and require the file again with no preview scheme anywhere in the written session, and require an ordinary blank browser tab beside it to still be called New Tab. Dispatch a title update out of a rendered preview's own guest and require it to reach the page state while the identity chip still reads the document's workspace-relative path. Attach a browsing guest and a workspace-document guest through the same method in one run and require the browsing one to take clicked-link routing, popup handling and anti-detection while the document guest takes none of them, stays inside the grant it is showing, denies every window it asks for, and is dropped from the page-keyed document registry by the same teardown that frees its id for a later attach. Register a browsing guest and attach a workspace-document guest in one run against the real manager, require the one door to answer each page with the guest of its own half, and require the document page to be absent from the browsing map and from its enumeration. Drive both browsing registration entry points with a page the document half already holds and require them to register nothing, and drive the mint channel with a page the browsing half already holds and require it to refuse; and drive the offscreen one with a guest that is missing and with one already destroyed, requiring the same refusal. Arm a grab on a live preview target, drive the unregister channel at that same target in the window before the queued operation runs, and require the grab to reach the guest anyway — then drive the same sequence for an ordinary browser page and require its grab state to be disposed after all. Hold one viewport-bridge op open, queue a second behind it, swap the page's guest while that second op waits, and require the injection to land in the guest the page has then. Revoke a grant after a tool has run against its page and require that page's grab state to be cancelled and disposed. Ask a tool for a document page whose guest has not attached, require the request to park in the registration wait, attach the guest, and require the same request to arm on it; require a page nothing ever renders to answer not-ready once that wait elapses. With a document open, ask a tool for a browsing page id and require it to be answered by the browsing half or not at all, with the same channel reaching the document guest under the page it really renders. Drive open-externally for a document whose per-file owner is remote while the workspace-scoped owner is unresolved, for a runtime-owned worktree whose preview tab carries no runtime id of its own, and for a worktree that resolves no runtime owner while the tab still carries one, requiring the download route in each; and for an owner that cannot be resolved at all, and for an unknown workspace root while nothing names another host, requiring a refusal that neither opens nor downloads. Drive the headless backend with a page the document half already holds and require it to reject, destroy the window, and unregister nothing — then shut the backend down and require it still to have unregistered nothing. Navigate a bound preview guest at a second grant through both latch events and require it to stay on the grant it bound to. Mount a preview while a renderer drag is already in flight and require its guest to be click-through at the moment it is appended, not a turn later. Render the editor panel shell in each remaining tab mode and require the path header exactly where the surface does not already name itself. Drive the preview action and require a browser tab located by the document rather than an editor tab, require a second open of the same document to activate the tab it is already in, and require closing that tab to revoke its grant while a URL tab closed beside it revokes none. Hydrate a session carrying preview chrome from a build that made previews editor tabs and require it dropped while the ordinary editor tab for the same document survives. Publish a worktree holding a document tab and a URL tab and require only the URL tab to reach the mobile snapshot. Install the shared partition policies for a preview partition and for an ordinary browsing partition in the same run, fire each one's own will-download listener, and require the preview's to cancel while the browsing one still reaches the download router — then require the preview protocol installer to be what asks for that deny. In the same run, require the cancelled download to raise a reader-facing notice and the routed one to raise none. Drive that notice directly for a guest bound to a live grant, for repeated attempts inside and outside its interval, for two previews at once, and for a contents no preview is bound to; require the guest registry to name the bound grant for a live preview guest and nothing for a contents that is not one, has committed no document, or is gone. Drive the shell with a refusal and require one fixed sentence, still one row after three more refusals, standing beside an asset failure rather than being counted with it, gone behind the failure panel, and ignored when it names another preview's grant. Drive the main-side report gate directly for an unfocused guest, a sender that is no preview guest, a guest with no bound or a revoked grant, and non-web URLs, and drive will-attach-webview in both preload directions. Run the per-owner reader, grant-containment, scheme-admission, guest-policy, and plan-routing contracts as unit oracles, including a host-reported truncation, an over-cap binary, and an out-of-worktree paired path. Drive the reader-facing component with the payloads the reader can actually produce — the entry document fails only as truncated or unreadable, a subresource additionally as a refused format — and require the asset case to leave the guest mounted. Drive the closed-tab cleanup hook, the window installer, and the grant IPC handlers directly, requiring the grant to be released when the preview tab closes, cleared at window creation and on a cross-document main-frame navigation, and refused to any sender that is not the trusted renderer. For the browser creations this gate still owns, run the unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host, requiring the original unsupported error or visible toast, zero RPCs, and no retained new split; hold a real navigation response beyond the 15-second client deadline after host creation and require the first RPC to return the exact host inventory page ID, one host page, and no retry; repeat reconciliation faults against headless serve and retain the separate exact-page reconciliation rollback oracle.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + // The hardening round: the third url door, the group projection, and both directions of the focus handoff. + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + // The changed-spec e2e job's own command, which is the only Linux run this gate has. + "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-browser-create-navigation-deadline.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", - "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" ], @@ -2863,7 +2880,6 @@ "src/main/runtime/orca-runtime-browser.test.ts", "src/main/runtime/rpc/methods/browser.test.ts", "src/renderer/src/lib/file-preview.test.ts", - "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", "src/renderer/src/runtime/web-session-browser-placement.test.ts", "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", @@ -2874,7 +2890,38 @@ "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts", "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts", "src/renderer/src/store/slices/browser.test.ts", - "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "src/main/browser/doc-preview-grant-registry.test.ts", + "src/main/browser/doc-preview-file-reader.test.ts", + "src/main/browser/doc-preview-protocol.test.ts", + "src/main/browser/browser-session-partition-policies.test.ts", + "src/main/browser/doc-preview-download-block-notice.test.ts", + "src/main/browser/browser-manager-guest-policy-profile.test.ts", + "src/main/browser/doc-preview-guest-policy.test.ts", + "src/main/ipc/browser-tab-registration-wait.test.ts", + "src/main/browser/doc-preview-failure-notice.test.ts", + "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "src/preload/doc-preview-link-interception.test.ts", + "src/main/window/main-window-webview-security.test.ts", + "src/main/ipc/browser-preview-tool-authorization.test.ts", + "src/main/browser/browser-manager-annotation-bridge.test.ts", + "src/main/browser/browser-manager-guest-lifecycle.test.ts", + "src/main/browser/offscreen-browser-backend-lifecycle.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts", + "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx", + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx", + "src/shared/doc-preview-scheme.test.ts", + "src/renderer/src/lib/doc-preview-grants.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "src/renderer/src/lib/file-preview-action-visibility.test.tsx", + "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", + "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "src/main/ipc/doc-preview-grant-ipc.test.ts", + "src/renderer/src/store/slices/tabs-hydration.test.ts", + "src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx", + "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", + "tests/e2e/paired-remote-html-preview-local-render.spec.ts", "tests/e2e/paired-browser-create-navigation-deadline.spec.ts", "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts" ], @@ -2886,18 +2933,13 @@ "direct creation removes the caller-declared new split without issuing an RPC" ] }, - { - "file": "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", - "assertions": [ - "known unavailability reports visibly without creating a split", - "capability loss after caller preflight reports visibly without duplicating transaction cleanup" - ] - }, { "file": "src/renderer/src/lib/file-preview.test.ts", "assertions": [ - "a failed side preview reports visibly without caller-owned split cleanup", - "overlapping side previews reuse the split without the first failure deleting it" + "a paired-runtime HTML preview opens a local doc preview instead of a runtime browser tab", + "a local workspace still opens the embedded browser tab at a file URL", + "a paired document outside the worktree names that boundary and creates no split", + "an SSH document outside the worktree root still previews" ] }, { @@ -2973,11 +3015,152 @@ ] }, { - "file": "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "file": "tests/e2e/paired-remote-html-preview-local-render.spec.ts", "assertions": [ - "one HTML open produces one client workspace, one client unified tab, and one host page", - "the source editor and remote frame remain visible in separate groups", - "preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal" + "the host's HTML document renders inside the client-local orca-preview guest", + "neither the client browser-workspace count nor the host browser-tab count moves off baseline", + "the preview opens in its own split with mode html-preview and leaves the source editor active", + "an explicit click reactivates the preview, and closing it preserves the source editor, its group, and the terminal", + "the document reports having attempted both its unattended and its post-press egress, so neither absence assertion can pass on a document that never tried", + "a scripted window.open and location.href driven by a real press elsewhere in the document route nothing and move no browser count", + "a real target=_blank press in the rendered document routes out as an Orca browser tab while the preview stays open" + ] + }, + { + "file": "src/main/browser/doc-preview-file-reader.test.ts", + "assertions": [ + "a host-reported truncated read is refused instead of served as partial bytes", + "an over-cap binary and an empty binary body are reported as too-large and unsupported rather than a bare read failure", + "a document outside the paired worktree is refused without issuing an RPC", + "an SSH path that canonicalizes outside the grant root is refused before any read", + "a 0-byte file the host still typed is served as 0 bytes rather than reported as a refused format" + ] + }, + { + "file": "src/main/browser/doc-preview-grant-registry.test.ts", + "assertions": [ + "a path escaping the grant root resolves to nothing, including sibling-prefix roots", + "a revoked grant stops resolving" + ] + }, + { + "file": "src/main/browser/doc-preview-protocol.test.ts", + "assertions": [ + "an unknown or revoked grant is answered exactly like a missing file, with no read attempted", + "a failed read pushes its reason for the requested path while a served document pushes nothing", + "every served document carries the self-only content security policy", + "the preview session cancels any request that is not in-document", + "the scheme is handled on the preview session and never on the default one", + "the scheme claims exactly its five privileges, so a widened one fails" + ] + }, + { + "file": "src/main/browser/doc-preview-failure-notice.test.ts", + "assertions": ["413 and 415 map to distinct reasons and everything else stays generic"] + }, + { + "file": "src/renderer/src/lib/file-preview-action-visibility.test.tsx", + "assertions": [ + "the Explorer row predicate and the preview plan return one answer for the same file" + ] + }, + { + "file": "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", + "assertions": [ + "an external preview link becomes a browser tab, and a tab that is refused or that fails outright surfaces instead of being dropped" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "assertions": [ + "each entry-document failure reason surfaces its own sentence instead of one generic unavailable panel", + "a refused subresource names itself in a notice, deduplicated per path, while the document stays rendered", + "an entry-document failure replaces the asset notice with the panel", + "a failure for another preview's grant leaves the rendered document alone" + ] + }, + { + "file": "src/main/browser/doc-preview-guest-policy.test.ts", + "assertions": [ + "the guest may only navigate within its own live grant, and not at all until a document has bound it to one", + "every navigation and popup sink refuses without routing, whenever genuine input happened", + "a subframe cannot navigate outside the grant, and only the main frame binds the guest to one", + "a reported click routes exactly once, and only from a focused preview guest with a live bound grant", + "a reported click on a non-web URL, or from a destroyed guest, routes nothing", + "the guest is denied non-proxied WebRTC UDP when its policy is installed" + ] + }, + { + "file": "src/preload/doc-preview-link-interception.test.ts", + "assertions": [ + "a click the document dispatched itself is not trusted and routes nothing", + "a trusted press on an external anchor reports it once and stops the navigation, including through what the anchor wraps and an SVG animated href", + "a fragment link scrolls in the document, raw or percent-encoded, and a bare hash goes to the top", + "a sibling preview document is left to the guest's own navigation, and a middle click opens nothing whether or not it is trusted", + "an SVG link is resolved against the document, so its relative, rooted and fragment forms behave exactly like the identical HTML anchor", + "the preload reports on exactly the channel main listens on" + ] + }, + { + "file": "src/main/window/main-window-webview-security.test.ts", + "assertions": [ + "an orca-preview src is admitted only on the preview partition with a live grant", + "preview guests receive their own policy instead of the browser guest policies", + "a preview attach is pinned to the preview preload over any renderer-supplied value, and a browsing attach never receives it", + "grants a renderer can no longer release are cleared at window creation and on reload, and survive an in-document navigation" + ] + }, + { + "file": "src/shared/doc-preview-scheme.test.ts", + "assertions": ["preview URLs round-trip per segment and reject a malformed grant id"] + }, + { + "file": "src/renderer/src/lib/doc-preview-grants.test.ts", + "assertions": [ + "grants are deduped per preview tab so a StrictMode double-mount cannot revoke the surviving webview", + "a mint that rejects after its tab was released and reopened leaves the reopened tab's grant revocable" + ] + }, + { + "file": "src/main/ipc/doc-preview-grant-ipc.test.ts", + "assertions": [ + "a sender that is not the trusted renderer mints no grant and revokes none", + "a reported link click reaches the guest policy with its own sender, and a non-string report reaches nothing" + ] + }, + { + "file": "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "assertions": [ + "closing a document browser tab revokes its grant, and closing a URL tab beside it revokes nothing", + "a document page's url is blank in the store, in the written session and after restore", + "a navigation committing a url onto a document page leaves the url blank, the document title standing and nothing loading, while an ordinary page beside it takes the url it is given" + ] + }, + { + "file": "src/renderer/src/store/slices/tabs-hydration.test.ts", + "assertions": [ + "chrome for a preview tab written before previews were browser tabs is dropped, and the ordinary editor tab for the same document is kept" + ] + }, + { + "file": "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", + "assertions": [ + "a browser tab located by a workspace document is not published to mobile clients, while an ordinary browser tab beside it is", + "with tab groups in play the document is kept out of the published groups too: no group's order, recency or active tab names a tab the snapshot does not carry, the held-back group leaves the published layout tree with it, and the grouped URL tab beside it still publishes" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx", + "assertions": [ + "the pane offers its guest focus when the reader is in this very preview", + "it refuses while the reader is in a terminal in front of it, in a different browser tab, or while its own page is not the active one" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "assertions": [ + "a preview that is the reader's surface hands its guest focus, and one that is not leaves focus alone", + "the window taking focus back re-offers it to the guest, and re-offers nothing for a preview that was refusing" ] }, { @@ -2999,22 +3182,301 @@ ], "evidenceRuns": [ { - "date": "2026-08-12", + "date": "2026-08-27", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", "result": "failed", - "durationSeconds": 0.72, - "summary": "The unchanged STA-4063 oracle failed 5/5 on scan e4c278eb52 and latest main bc2e30000b: direct create never closed the new split, and side-preview rejection produced neither cleanup nor a toast. Pre-fix PR #14100 passed the two caller cases but still failed all three direct transaction cases." + "durationSeconds": 7, + "summary": "The adversarial pass answered the request for its own group-projection probe after this round landed, and it had measured a third observable this test was missing: the held-back group also has to leave the published layout tree, or the phone renders a split whose second pane can never hold anything. Adopted as a fourth assertion and re-run against the same sole delta, which now fails on the dangling-reference check and, with that check blinded, on the layout alone — group-left and group-right against the group-right the clean tree publishes. The two probes agreed on everything else, including that the tab list is unchanged under this mutant, which is why an assertion reading only the tab list cannot reach the site at all." }, { - "date": "2026-08-12", + "date": "2026-08-27", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 3, + "summary": "The adversarial pass's four coverage findings, landed and pinned: 58/58. The third url door now keeps a document page blank and names it by its document, the group projection holds a document back from published groups as well as from the tab list, the pane's focus handoff is pinned in the widening direction as well as the narrowing one, and the handoff is re-offered when the window takes focus back." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Three sole-delta mutants on the third url door, each failing only the new store test: committing the reported url onto a document page, dropping the document from the title normalizer there (which renames the tab New Tab, since a blank url is what that fallback reads), and marking a blank document page loading. The ordinary-page half of the same test passes under all three, so the door is pinned as a document-only refusal rather than as a door that writes nothing." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Two sole-delta mutants on the mobile publish boundary. Dropping the document from the group projection's browser ids fails only the new grouped test — the tab list stays correct because the per-tab guard still refuses the tab, and what breaks is a published group whose order and active tab name a tab the phone is never sent. This is the survivor the adversarial pass reported: it survives the whole suite without this test, because no other test in it has tab groups. Dropping the per-tab guard instead fails only the ungrouped test, so the two halves of the boundary are pinned separately." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Three sole-delta mutants widening the focus handoff, the direction the adversarial pass found uncovered: dropping the page-activity half, dropping the active-tab-type half, and dropping the active-browser-tab half each fail exactly one of the pane's refusals — the inactive page, the terminal in front, and the other browser tab respectively. The presence half of that test passes under all three, so a pane that had stopped offering focus at all would still fail rather than pass the refusals vacuously." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "One sole-delta mutant dropping the window-focus re-offer: the guest is focused once at mount and never again after the window comes back, which is the desktop gap behind it — another app taking the front leaves the preview's only route out shut until the pane remounts. It fails only the new re-offer test, and the refusing-preview case beside it stays green, so the re-offer is pinned as gated rather than unconditional." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 186, + "summary": "The rewritten journey passed 2/2. The first test proves the inverted claim: the client gains exactly one browser workspace and it is the document one — page and tab url blank, the document path mirrored onto both, the tab named by the document's own title, the chip still naming the file, no editor row of the retired preview species — while the host's page registry and tab snapshot both stay at their pre-action content. Both host oracles carry a presence precondition in the same run: an ordinary URL browser tab this client opens does arrive in browser.tabList and in session.tabs.list, and leaves both again when it is closed, so the preview's absence is a decision rather than an oracle that cannot see browser pages. The document still reaches nothing on its own (zero ICE candidates, unattended and post-press egress both swallowed), and the one route out still works: a real press on a target=_blank anchor routes exactly one call and opens a tab while the preview stays open. The second test quits the client with a document tab open and relaunches it on the same profile: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 108, + "summary": "Candidate with the sole delta that the preview action also opens an ordinary browser tab through the runtime — what it did before STA-5557 — failed at the client-workspace count, 2 against the 1 the claim allows. It dies there rather than at the host oracle, so it proves the exactly-one-workspace half; the host half has no single line to mutate, since a document page never calls the runtime at all, and is protected instead by the presence precondition recorded above." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 190, + "summary": "Candidate with the sole delta that the workspace row stops mirroring the document its active page shows failed in both tests on workspaceDocFilePath, null against the document path — the tab entry would go on naming nothing while the page it renders is a document. Two earlier mutants survived and are reported as ill-posed rather than as blind spots: flipping the mobile publish filter changes what this desktop publishes to its own phone clients, not what a paired client puts on its host, and flipping the document mirror in the workspace constructor is repaired on the next state write by the active-page mirror, which is the line the mutant above hits." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 195, + "summary": "Candidate with the sole delta that the preview pane does not hand focus to its guest — the tree as it stood before this change — failed at the target=_blank press: six real presses reached the anchor, the embedder had the webview as its active element, and main's own log answered each report with registered true, grant live, focused false. This is the gap the rewrite found: since previews became browser pages they had no chrome to pass focus on, so the one route out of a preview was shut." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "result": "passed", + "durationSeconds": 1.37, + "summary": "174/174 over the grant, reader, protocol, guest-policy, scheme-admission, plan-routing, action-visibility, failure-message, external-link and terminal-row contracts, re-issued at the relocated preview paths. This replaces the 2026-08-26 record of the same thirteen files at 152/152, whose command named the pre-relocation paths: it established the stale-mint rejection that must not evict a reopened preview tab, the CSP header and session request filter, the SSH symlink refusal, subframe containment, truncated and over-cap reads, the unservable-asset and empty-file split, the out-of-worktree paired refusal with SSH left unrestricted, reload re-minting a stale grant, and the guest deny-only navigation sinks. The count moved with the cases added since; nothing in those files changed but their directory." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "passed", + "durationSeconds": 1.78, + "summary": "64/64 on the visible download refusal, re-issued at the relocated path. This replaces the 2026-08-27 record of the same four files at 62/62, whose ten mutants — removing the notice from the deny listener, adding it to the routing listener, dropping the guest-registry lookup that names which preview to tell, dropping the shell branch and the row, letting the row survive the failure panel, removing the per-preview throttle, sharing one throttle across previews, never expiring the window, and publishing for a contents no preview is bound to — all failed as sole deltas. The relocation moved no line of any of them." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts", + "result": "passed", + "durationSeconds": 2.05, + "summary": "39/39 on the title-parity trio at the relocated path, replacing the 2026-08-27 record of the same three files, which killed five mutants as sole deltas: disabling the document branch restores New Tab for a named document, dropping the grant-URL refusal puts the scheme into the title, the tab mirror and the written session, returning New Tab instead of the file name breaks the fallback, removing the guest listener leaves the document unable to name its tab, and making the scheme test parse rather than prefix-match lets a title that merely looks like a preview URL through." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "passed", + "durationSeconds": 1.54, + "summary": "14/14 on the reader-facing failure suite at its relocated path. This and the red run below replace the 2026-08-26 pair recorded against the pre-relocation path (10/10 green, 3/10 red), re-measured today so the entry/subresource split keeps a red half rather than only a green one." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "failed", + "durationSeconds": 1.27, + "summary": "Candidate with the sole delta that every failure sets the blocking reason, asset or not, failed 4/14 on the asset-notice assertions — the same mutant the retired 2026-08-26 record measured at 3/10, re-run at the relocated path. A refused subresource must name itself over a document that stays mounted; only the entry document may take the page away." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 1.52, + "summary": "174/174 at the relocated paths, replacing the 2026-08-27 records of the same command at 162/162 and 158/158. Those established, as sole deltas: removing the unregister-channel refusal goes red because the grab disposal beside it drops the intent an in-flight preview grab compares by identity, while an ordinary browser page driven through the identical sequence still has its grab state disposed; removing registration’s already-gone-guest refusal goes red for both a missing and a destroyed guest; removing the map deletion on the refusal path goes red at shutdown; the bridge’s own unregisterGuest branch, restored, tears down a live page over a misaddressed request; and the download fall-through for an unresolved owner hands back a same-named local file under a remote document’s name." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 1.5, + "summary": "175/175 at the relocated paths on the unified tool authority, replacing the 2026-08-27 record of the same command at 174/174. That run killed twelve of fourteen mutants as sole deltas — dropping the document branch from the one authority door, removing either browsing-registration refusal or the mint-side refusal that is its converse, deleting the registration notification or widening it to the worktree-wide and any-tab waits, disposing revoked state by grant instead of by page, dropping the hosting-renderer or destroyed-contents check, rebuilding the renderer tool target from the grant, and deleting the teardown identity check under a re-mint — and reported two as ill-posed rather than as kills. The relocation changed no file it names except their directory." + }, +{ + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts", + "result": "passed", + "durationSeconds": 22.09, + "summary": "29/29 on the candidate that makes the preview a browser tab. The preview action now creates a page located by the document; reopening the same document activates the tab it is already in rather than minting a second grant on one file; and closing that tab revokes its grant, which nothing else does now that the editor tab's close hook is gone. Red-green with each mutant as the sole delta: dropping the reuse lookup opens a second tab for a document already on screen, and dropping the release on close leaves the document readable through a grant nothing revokes until the process ends. Both are paired with presence preconditions in the same runs — a second, different document still gets its own tab, and a URL tab closed beside the document tab revokes nothing, so a release fired for every close would fail rather than pass." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", + "result": "passed", + "durationSeconds": 4.77, + "summary": "40/40 on the candidate that removes the editor preview species and moves its two boundary duties onto the browser tab. The publish filter moved with the tab: it used to exclude an editor file by mode, and now excludes a browser workspace by whether it is located by a document — asserted at both the group projection and the tab loop, because a mutant that filters only one of them still publishes. Migration is the other half: sessions written by builds that made previews editor tabs carry chrome whose entity id encodes the document, and whose document was never persisted, so it has always come back naming a surface no restore produces; that chrome is now dropped. Each mutant as the sole delta: filtering neither place publishes the document tab to mobile, filtering only the projection still publishes it, and removing the migration leaves the stale strip entry. Presence preconditions in the same runs: an ordinary browser tab beside the document tab does publish, and the ordinary editor tab for the very same document survives hydration." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "result": "passed", + "durationSeconds": 1.54, + "summary": "10/10 on the candidate that gives a browser page a workspace-document location. The trap this is built around: page.url is read by persistence, the mobile publisher, history and the address bar, so a grant URL parked there would outlive the grant that named it and travel to readers that cannot resolve it. Creation overrides the url rather than trusting its caller, and restore re-asserts the same thing, so a session written by another build cannot carry one in. Red-green with each mutant as the sole delta: dropping the creation override writes the grant URL into the store and into the session payload; dropping the restore override brings a foreign session's grant URL back; leaving docLocation out of either browser schema strips it on load and restores the document as a blank New Tab under a strip entry that still names it (both schemas are plain z.object, and this is the trap that already cost one round on the editor schema); dropping it from the workspace build or the mirror leaves the tab entry naming a document its page has left, which the publish boundary reads; dropping the salvage-path copy loses the document when only the tab row survived; and dropping the address-bar exclusion focuses an address bar the surface does not have. The blank-url assertions are paired with a presence precondition in the same run: an ordinary tab created through the same call keeps its URL and does ask for the address bar." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", + "result": "passed", + "durationSeconds": 0.32, + "summary": "55/55 on the candidate that gives every guest one attach door. A preview used to reach its policies through an installer of its own, which meant the manager's attachment bookkeeping — the set registration consults and the cleanup teardown runs — never knew a preview existed. It is now a profile on `attachGuestPolicies`, and the doc policy is what that profile installs. Red-green with each mutant as the sole delta: deleting the profile branch hands a previewed document the browsing machinery (clicked-link routing, frame routing, popup child windows, anti-detection over the debugger) and lets it navigate off-grant; dropping the manager's destroyed wiring leaves the guest answerable through the grant-keyed authority after it dies and its id marked attached forever; and making the window fork ask for the default profile is caught at the fork itself. The absence assertions are paired with a presence precondition in the same run: a browsing guest attached through the same method observably takes every one of those things, so a profile that fenced nothing, or an attach path that had stopped installing anything at all, fails rather than passing empty." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", + "result": "passed", + "durationSeconds": 0.17, + "summary": "22/22 on the candidate that stops a previewed document writing into this desktop's Downloads folder. The hole was read out of the shipping path, not guessed: the preview partition took the shared browser partition policies whole, including will-download, and a preview guest joins no browser-page registry, so handleGuestWillDownload resolved no owner context, routeBrowserClientDownload answered local for a non-client-hosted contents, and the item got a reserved Downloads name with no prompt and no tab to attribute it to. Downloads are now the one policy the preview partition does not inherit. Red-green with each mutant as the sole delta: making the installer ignore the deny option routes the preview partition's download to the router again, and making the preview protocol installer stop asking for the deny leaves the option unset. The absence half is paired with a presence precondition in the same run — an ordinary browsing partition installed beside the preview one in one test still reaches the download router, so a deny that fenced everything, or a download path that had been removed entirely, would fail rather than pass quietly." + }, +{ + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", "result": "passed", "durationSeconds": 0.7, - "summary": "Rebased candidate 14b5877a75 passed 5/5: absent, unknown, mixed-version, preflight-known, and post-preflight capability failures all reported failure and retained no new empty split." + "summary": "99/99 on the candidate that binds a preview guest to its grant when the policy installs, not only on a later navigation. Live on a dev instance against a real SSH workspace, the previous shape refused every tool with not-ready for as long as the reader stayed on the page they opened: the embedder hands the guest over after it has already started loading its src, so the did-start-navigation latch never saw the only navigation most previews make. Following an in-document link bound it and the tools began answering, which is what identified the miss. Two cases pin it: a guest already reporting its URL at install, and one whose first navigation only reaches the commit." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 23.1, + "summary": "The journey passed 1/1 on the candidate that requires the guest to be bound to a grant before any navigation is allowed, rather than admitting an unbound one. The document still rendered and the target=_blank press still routed out, which is the half that would break if the renderer-set src reached will-navigate: it does not, because that load is browser-initiated, so the permissive branch it was written for is unreachable." + }, + { + "date": "2026-08-26", + "runner": "ci", + "platform": "linux", + "command": "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1", + "result": "failed", + "durationSeconds": 72, + "summary": "The journey failed on Ubuntu at the post-press presence precondition, with #post-input-egress still reading 'idle' after 30 seconds. The trace shows the press was dispatched at 769.5,143.9, a point the guest's own hit test had just confirmed was on its heading, and the focus report before it was already isFocused true, so neither focus nor coordinates were the break: the press never entered the guest at all. A standalone Electron 43 probe under Xvfb with this suite's Linux flags reproduced it in roughly two runs in five when pressing immediately after guest attach — the embedder received the pointerdown with the webview element as its target, meaning the browser resolved the press against the embedder because the freshly attached guest had not yet registered its hit-test region. The same probe never dropped a press on macOS, which is why local runs of either project could not see this. The spec now presses until the document answers, mirroring the target=_blank phase." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 72, + "summary": "The journey passed 3/3 on the trusted-click candidate, which removed the recent-input gate, made both navigation sinks deny-only, dropped the webview's allowpopups attribute and gave the preview guest its own pinned preload. The fixture now also attempts a window.open and location.href from inside a pointerdown handler, so the egress rides a press the reader really made: it reported 'attempted' every run while both browser counts stayed at that phase's baseline and nothing routed, and the later real press on the target=_blank link still opened exactly one Orca browser tab with the preview still open." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 26.9, + "summary": "Candidate with the sole delta that the navigation sinks route again behind a two-second recent-input gate failed on clientBrowserWorkspaceCountAllWorktrees 1 versus baseline 0: the document's location.href, run from inside the handler for the reader's press on the heading, opened a real browser tab. That is the case the earlier design could not distinguish from the press's own effect, and it is why the sinks route nothing at all now. The same run still refused the unattended on-load egress, so this measures the new half specifically." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", + "result": "passed", + "durationSeconds": 0.97, + "summary": "97/97 passed over the preview tool-authorization seam. The channel census asserts the split is total, so a new browser channel cannot be added without classifying it: all six tool channels resolved the preview guest and refused both a renderer that does not host the preview and a grant no preview rendered, while all twenty-three page/session channels never reached the preview authority at all. Five mutations were killed \u2014 dropping the preview branch (12 failures), turning the two authorities into a fallback chain (13), ignoring the hosting renderer id (6), ignoring grant revocation (1), and removing one channel from the census (1)." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts", + "result": "passed", + "durationSeconds": 0.36, + "summary": "The four surfaces the trusted-click route is built from passed 63/63: the preload's click policy over a real document, including an untrusted dispatched click, an untrusted middle click, an SVG animated href resolved against the document so a relative, rooted or fragment SVG link behaves exactly like the identical HTML anchor, fragment and percent-encoded fragment targets and a trusted middle click; the guest policy's deny-only sinks, its refusal of a navigation that arrives before a document has bound the guest to a grant, and its report gate for an unfocused guest, an unregistered sender, a missing or revoked grant and non-web URLs; will-attach-webview pinning the preview preload in both directions; and the click channel handing main its own sender without a trusted-renderer check." + }, +{ + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 22.8, + "summary": "The journey passed 1/1 with the fixture document now attempting its own egress on every load: an unattended window.open and location.href to an off-machine URL, and an ICE gathering probe. Both browser counts held at baseline and the probe reported candidates=0, while the document still rendered and the later real target=_blank press still routed out as an Orca browser tab." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 23.6, + "summary": "Candidate with the sole delta that the preview guest no longer receives the disable_non_proxied_udp IP-handling policy failed on candidates=6 versus 0, proving the document can gather ICE — including a STUN reflexive candidate against an off-machine server in the standalone probe — whenever that one main-process call is missing." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 66, + "summary": "The journey passed 3/3 again under --repeat-each=3 on the candidate that clears grants on every fresh shell document and gates the mint channel on the trusted renderer, so neither change disturbs a live preview: the document still rendered, both browser counts held at baseline, and the target=_blank press still routed out as an Orca browser tab." + }, +{ + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 59.7, + "summary": "The renamed journey passed 3/3 under --repeat-each=3 on the hardened candidate: the host document rendered in the orca-preview guest, both browser counts held at baseline through the split, focus, reactivation and close phases, and the final phase's real target=_blank press routed out as an Orca browser tab with the routing call recorded as opened while the preview tab stayed open." }, { "date": "2026-08-12", @@ -3070,78 +3532,6 @@ "durationSeconds": 3.48, "summary": "Seven focused host and renderer contract files passed 183/183 tests, covering owning-runtime routing, focused and unfocused creation, placement, reconciliation, failure cleanup, and teardown." }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", - "result": "passed", - "durationSeconds": 16.1, - "summary": "The candidate passed exact host/client cardinality, split stability, rendered DOM, focus authority, close convergence, and surviving editor and terminal." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 11.6, - "summary": "Latest-main a90a18d43da333e1cc327779a3f9912a78308848 failed the byte-identical 5806b23f oracle because the HTML preview collapsed into the source editor group instead of retaining its requested split." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 6.5, - "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e passed the byte-identical 5806b23f oracle: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 10.9, - "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e with only the side-preview owning-runtime routing seam disabled failed the byte-identical 5806b23f oracle with two client workspaces." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 7.6, - "summary": "Final candidate d5767481b982b736e7cc5b4653b2a32a61280638 on merge-base 36d45af062ec4022265aabac8fc6a9d36eecedf1 passed the byte-identical 5806b23f oracle after final review and rebase: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 11.8, - "summary": "Latest-main 346e59c879 failed the byte-identical 2ec1dcf1 oracle with two client HTML browser workspaces instead of one." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 8.4, - "summary": "Candidate d05217c40d passed the byte-identical 2ec1dcf1 oracle: one host page, one client workspace, stable split, rendered DOM, editor focus after creation, explicit-click browser focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 12.3, - "summary": "Candidate d05217c40d with only the side-preview focusOnCreate:false call disabled failed the byte-identical 2ec1dcf1 oracle because preview creation activated the browser instead of preserving editor focus." - }, { "date": "2026-08-13", "runner": "local", @@ -3171,7 +3561,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing." + "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing. For the STA-5557 preview behavior the rewritten journey is green 1/1 on the candidate and red when the sole delta makes the same preview action also create a browser tab, failing on clientBrowserWorkspaceCount 1 versus baseline 0. That absence assertion is gated on first reading the rendered document out of the guest, so it cannot pass by the preview never happening. Against pre-STA-5557 builds the journey is red by construction rather than by measurement: those builds have no html-preview tab mode, so the run would fail earlier, at materialization, and it has not been executed against an old build. The reader-facing half is red-green at the unit layer. The 6/6 green and 2/6 red pair recorded earlier on 2026-08-26 measured the superseded shape, where a subresource failure was filtered out entirely; the shipping shape is green 10/10 and red 3/10 when the sole delta makes every failure, asset or entry, set the blocking reason. The containment lines the second review found uncovered are red-green too: installing the scheme handler on the default session instead of the preview session, and widening the scheme privileges with allowServiceWorkers and bypassCSP, each fail the protocol suite as the sole delta, and deleting the html-preview case from the closed-tab cleanup fails the hook suite. The external-link phase added on 2026-08-26 is red-green by measurement: green 3/3 on the trusted-click design, in which the guest's own preload reports a trusted press on an anchor and main answers only a focused preview guest holding a live grant. Its earlier shape depended on the webview carrying allowpopups and went red with zero recorded routing calls when only that attribute was dropped; the preload now intercepts the press before Chromium considers a popup, so the attribute is gone and that measurement retired with it. One further run failed in the paired-client harness before reaching any preview step, when the client window never exposed its store within the launch timeout; that is a harness flake, not a preview signal. The two egress fences added on 2026-08-26 are red-green by measurement in the same journey, whose fixture attempts its own escape on every load and again from inside the handler for the reader's press on the heading: with deny-only navigation sinks the document opens no browser tab and the counts hold at baseline, and with the sole delta that those sinks route again behind a two-second recent-input gate the post-press location.href opens a real tab, clientBrowserWorkspaceCountAllWorktrees 1 against a baseline of 0. An earlier measurement of that same gate, taken when it was the shipping design, showed the unattended on-load egress opening two tabs once the gate was removed entirely; it was retired with the gate, because a gate that cannot tell a script's navigation from the click it followed was never the fence the claim needed. With the guest's IP-handling policy the document reports candidates=0, and with the sole delta that the policy call is removed it reports candidates=6. Both mutants were applied to the shipping module, rebuilt, and run against the live paired journey rather than reasoned about." }, "performanceBudget": { "required": true, @@ -3184,9 +3574,14 @@ "knownGaps": [ "Live paired-server validation currently covers macOS only.", "Multiple simultaneous viewers are not exercised by the Electron journey.", - "Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path." + "Local workspaces intentionally keep the embedded browser tab at a file URL and are not covered by the preview journey.", + "The SSH preview owner has unit coverage only; no live SSH host renders a preview in this gate.", + "The preview journey exercises one self-contained document. Relative subresources, reload re-reading the workspace disk, and the truncated and out-of-worktree refusals are covered by unit contracts only.", + "Grant revocation on tab close is asserted at the unit layer; the journey observes the tab and open file disappearing, not the main-process registry.", + "The external-link route trusts a real press on an anchor in a focused preview guest, so it proves the reader pressed that anchor, not that the anchor's visible text matches its target. The tab it opens is visible, which is the remaining containment.", + "WebRTC is fenced by the IP-handling policy rather than by removing the API; the guest can still construct a peer connection, and the journey measures that it gathers nothing." ], - "demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, steal editor focus before an explicit browser click, lose browser focus after that click, or remove unrelated editor or terminal state on close." + "demotionRule": "Demote if a paired HTML preview creates a browser workspace on either side, renders from anything other than the orca-preview scheme, serves a read the host reported as truncated or over-cap, serves a document without the self-only content security policy, lets the preview session carry an off-machine request, reads an SSH path that canonicalizes outside its grant root, steals focus from the source editor when it opens, removes unrelated editor or terminal state on close, hides a document that rendered because one subresource was refused, leaves a grant readable after the tab or renderer that owns it is gone, mints one for a sender that is not the trusted renderer, opens a browser tab for a navigation the document started with no one at the keyboard, or lets the guest gather an ICE candidate; or if a paired browser creation this gate still owns produces duplicate client or host identities or loses its requested split." }, { "id": "browser-network-tunnel.bounded-fail-closed-route", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f81dbb93041..4faa844ee4c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = { index: resolve('src/main/index.ts'), // Why: sandboxed webview preloads cannot load Rollup helper chunks. 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), + 'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), diff --git a/src/main/browser/browser-manager-annotation-bridge.test.ts b/src/main/browser/browser-manager-annotation-bridge.test.ts new file mode 100644 index 00000000000..2d9aa5e50fe --- /dev/null +++ b/src/main/browser/browser-manager-annotation-bridge.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) + +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' +import { + rendererWebContentsId, + resetBrowserManagerMocks, + resetBrowserManagerState +} from './browser-manager-test-harness' +import { + createViewportGuestFactory, + flushViewportOps +} from './browser-manager-viewport-test-fixtures' + +const { webContentsFromIdMock } = browserMocks +const makeGuest = createViewportGuestFactory(browserMocks) + +const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = { + emitViewport: false, + enabled: true, + markers: [], + token: 'annotationviewporttoken' +} + +function registerPage(pageId: string, guest: Record): void { + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: pageId, + webContentsId: guest.id as number, + rendererWebContentsId + }) +} + +/** The production resolver's shape: read the registry now, not when the request was made. */ +function resolveFromRegistry(pageId: string): () => Electron.WebContents | null { + return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId) +} + +describe('browserManager.setAnnotationViewportBridge', () => { + beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('installs the bridge in an isolated world', async () => { + const { guest } = makeGuest(4646) + registerPage('tab-annotations', guest) + + const ok = await browserManager.setAnnotationViewportBridge( + 'tab-annotations', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-annotations') + ) + + expect(ok).toBe(true) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( + expect.any(Number), + [ + expect.objectContaining({ + code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') + }) + ], + false + ) + }) + + // Why this is the case that matters: a cross-process navigation re-registers the same page id + // with a new WebContents and leaves the retired one alive, so a bridge op that resolved its + // guest when the request arrived would inject into a page nobody is looking at — and the + // annotation badges would stop tracking the document on screen. + it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => { + const { guest: firstGuest } = makeGuest(5101) + let releaseFirstInjection = (): void => {} + // One shared gate for every call, so a wrongly-routed second injection still settles and the + // test fails on where it landed rather than on a timeout. + const firstInjectionGate = new Promise((resolve) => { + releaseFirstInjection = () => resolve() + }) + firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate) + registerPage('tab-swap', firstGuest) + + const firstDone = browserManager.setAnnotationViewportBridge( + 'tab-swap', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-swap') + ) + await flushViewportOps() + + // A second request arrives while the first still holds the chain — at this moment the page is + // still on the first guest, which is the guest a request-time resolution would capture. + const secondDone = browserManager.setAnnotationViewportBridge( + 'tab-swap', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-swap') + ) + await flushViewportOps() + + // Only now does the page swap renderer processes, while the second op is still queued. + const { guest: secondGuest } = makeGuest(5102) + registerPage('tab-swap', secondGuest) + + releaseFirstInjection() + await expect(firstDone).resolves.toBe(true) + await expect(secondDone).resolves.toBe(true) + + expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) + expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) + }) + + // Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry + // is the one that can see the guest is gone. The bridge only reports the refusal. + it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => { + const { guest } = makeGuest(5103) + registerPage('tab-dies', guest) + expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103) + + webContentsFromIdMock.mockReturnValue(null) + await expect( + browserManager.setAnnotationViewportBridge( + 'tab-dies', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-dies') + ) + ).resolves.toBe(false) + + // Why assert the registry and not just the answer: a stale guest has to clear every per-tab + // entry, or the page keeps a dead WebContents id that later ops resolve against. + expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull() + }) + + // Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by + // the wrong renderer names a page that is alive and on screen, and answering it with teardown + // would cancel that page's in-flight downloads and grabs over a misaddressed message. + it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => { + const { guest } = makeGuest(5105) + registerPage('tab-mismatch', guest) + const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest') + + await expect( + browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () => + browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1) + ) + ).resolves.toBe(false) + + expect(unregisterGuest).not.toHaveBeenCalled() + expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105) + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + it('refuses a destroyed guest without injecting into it', async () => { + const { guest } = makeGuest(5104) + registerPage('tab-destroyed', guest) + ;(guest.isDestroyed as ReturnType).mockReturnValue(true) + + await expect( + browserManager.setAnnotationViewportBridge( + 'tab-destroyed', + BRIDGE_OPTIONS, + () => guest as never + ) + ).resolves.toBe(false) + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + // Why: a document page owns no browsing state, so treating its refusal as a stale page would run + // teardown against an id the browsing registry never held. + it('does not run page teardown when a document page resolves to nothing', async () => { + const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest') + + await expect( + browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null) + ).resolves.toBe(false) + + expect(unregisterGuest).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts index d1a6a16c085..6d71db94585 100644 --- a/src/main/browser/browser-manager-guest-lifecycle.test.ts +++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts @@ -45,6 +45,32 @@ import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' + +/** + * A page the document half of the registry really holds. Built rather than named: membership is + * what both doors refuse on now, so an id that merely looks like a preview's would be admitted. + */ +function registerWorkspaceDocPage(browserPageId: string): void { + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId + }) + const guest = { + isFocused: () => false, + isDestroyed: () => false, + getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath), + on: vi.fn(), + once: vi.fn(), + setWindowOpenHandler: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn() + } + installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() }) +} const { guestOffMock, @@ -60,6 +86,7 @@ describe('browserManager', () => { beforeEach(() => { resetBrowserManagerMocks(browserMocks) resetBrowserManagerState() + revokeAllDocPreviewGrants() }) afterEach(() => { @@ -92,6 +119,77 @@ describe('browserManager', () => { expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') }) + // Why both doors: one id in both halves of the registry would make the tool door answer with a + // document guest for a page the reader is browsing in. + it.each(['registerGuest', 'registerOffscreenGuest'] as const)( + 'refuses %s for a page the document registry already holds', + (entryPoint) => { + const guest = { + id: 129, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + const browserPageId = 'doc-page-1' + registerWorkspaceDocPage(browserPageId) + + if (entryPoint === 'registerGuest') { + expect( + browserManager.registerGuest({ + browserPageId, + webContentsId: guest.id, + rendererWebContentsId + }) + ).toBe(false) + } else { + expect( + browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id }) + ).toBe(false) + } + + expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() + } + ) + + // Why this answer is load-bearing: the headless backend destroys its window on false, so a true + // for a guest that is already gone would leave a page id registered onto nothing. + it.each(['missing', 'destroyed'] as const)( + 'refuses registerOffscreenGuest when the named guest is %s', + (guestState) => { + webContentsFromIdMock.mockReturnValue( + guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) } + ) + + expect( + browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 }) + ).toBe(false) + + expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull() + } + ) + + // Why the exit door needs the same check: a document page withdraws by revoking its grant, so its + // id here is misaddressed — and unregistering opens by evicting whatever grab that id names. + it('refuses unregisterGuest for a page the document registry holds', () => { + registerWorkspaceDocPage('doc-page-2') + const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp') + + browserManager.unregisterGuest('doc-page-2') + + expect(cancelGrabOp).not.toHaveBeenCalled() + + // The presence half: the same door does evict a browsing page's grab. + browserManager.unregisterGuest('browser-page-1') + expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted') + cancelGrabOp.mockRestore() + }) + it('blocks non-web guest navigations after attach', () => { const guest = { isDestroyed: vi.fn(() => false), diff --git a/src/main/browser/browser-manager-guest-policy-profile.test.ts b/src/main/browser/browser-manager-guest-policy-profile.test.ts new file mode 100644 index 00000000000..47871f64ea9 --- /dev/null +++ b/src/main/browser/browser-manager-guest-policy-profile.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' + +type GuestFake = { + id: number + url: string + listeners: Map void)[]> + windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null + webRtcPolicy: string | null + isDestroyed: () => boolean + isFocused: () => boolean + getURL: () => string + getType: () => string + setBackgroundThrottling: (value: boolean) => void + setWebRTCIPHandlingPolicy: (policy: string) => void + setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void + executeJavaScriptInIsolatedWorld: ReturnType + debugger: { + isAttached: () => boolean + attach: ReturnType + sendCommand: ReturnType + } + on: (event: string, listener: (...args: never[]) => void) => void + once: (event: string, listener: (...args: never[]) => void) => void + off: (event: string, listener: (...args: never[]) => void) => void +} + +function createGuest(id: number, url: string): GuestFake { + const listeners = new Map void)[]>() + const guest: GuestFake = { + id, + url, + listeners, + windowOpenHandler: null, + webRtcPolicy: null, + isDestroyed: () => false, + isFocused: () => true, + getURL: () => guest.url, + getType: () => 'webview', + setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: (policy: string) => { + guest.webRtcPolicy = policy + }, + setWindowOpenHandler: (handler) => { + guest.windowOpenHandler = handler + }, + executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined), + debugger: { + isAttached: () => true, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined) + }, + on: (event, listener) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]) + }, + once: (event, listener) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]) + }, + off: (event, listener) => { + listeners.set( + event, + (listeners.get(event) ?? []).filter((entry) => entry !== listener) + ) + } + } + return guest +} + +function listenerCount(guest: GuestFake, event: string): number { + return guest.listeners.get(event)?.length ?? 0 +} + +/** Drives the guest's own will-navigate listeners and reports whether they refused. */ +function navigateTo(guest: GuestFake, url: string): boolean { + let prevented = false + const event = { preventDefault: () => (prevented = true) } as never + for (const listener of guest.listeners.get('will-navigate') ?? []) { + ;(listener as (event: unknown, url: string) => void)(event, url) + } + return prevented +} + +const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents + +beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + revokeAllDocPreviewGrants() + browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined) +}) + +describe('guest policy profiles', () => { + function attachPreviewGuest(id = 301): { + guest: GuestFake + grantId: string + browserPageId: string + } { + const browserPageId = `doc-page-${id}` + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId + }) + const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html')) + browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host }) + return { guest, grantId: grant.id, browserPageId } + } + + // The presence half of every absence below: a browsing guest observably takes all of it through + // the same method, so a profile that fenced nothing — or an attach path that stopped installing + // anything at all — cannot pass these by being uniformly empty. + it('gives a browsing guest link routing, popups and anti-detection', () => { + const guest = createGuest(300, 'https://example.com/') + + browserManager.attachGuestPolicies(guest as never) + + expect(listenerCount(guest, 'dom-ready')).toBe(1) + expect(listenerCount(guest, 'frame-created')).toBe(1) + expect(listenerCount(guest, 'did-create-window')).toBe(1) + expect(guest.debugger.sendCommand).toHaveBeenCalled() + expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false) + }) + + it('gives a workspace-document guest none of it', () => { + const { guest } = attachPreviewGuest() + + expect(listenerCount(guest, 'dom-ready')).toBe(0) + expect(listenerCount(guest, 'frame-created')).toBe(0) + expect(listenerCount(guest, 'did-create-window')).toBe(0) + expect(guest.debugger.sendCommand).not.toHaveBeenCalled() + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + it('holds a workspace-document guest inside the grant it is showing', () => { + const { guest, grantId } = attachPreviewGuest() + + expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false) + expect(navigateTo(guest, 'https://example.com/')).toBe(true) + expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp') + }) + + it('denies every window a workspace-document guest asks for', () => { + const { guest } = attachPreviewGuest() + + expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({ + action: 'deny' + }) + }) + + // Why teardown and not just install: registration and teardown both key on the guest having been + // policy-attached, so a profile that installs outside that bookkeeping leaves the id marked + // attached forever and stays answerable to tools after the surface is gone. + it('tears a workspace-document guest down through the same policy cleanup', () => { + const { guest, browserPageId } = attachPreviewGuest(302) + expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never) + + for (const listener of guest.listeners.get('destroyed') ?? []) { + listener() + } + + expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull() + // The manager's own bookkeeping was reached too: a second attach is refused while the id is + // still marked policy-attached, and accepted once its teardown has run. + browserManager.attachGuestPolicies(guest as never) + expect(listenerCount(guest, 'dom-ready')).toBe(1) + }) + + // The seam the whole split rests on: one public door answers for both halves, and each page id + // resolves in exactly one of them. Asserted against the real manager, because the IPC census + // test has to mock it. + describe('the one door across both halves of the registry', () => { + const BROWSING_PAGE_ID = 'browser-page-1' + + function registerBrowsingGuest(id = 400): GuestFake { + const guest = createGuest(id, 'https://example.com/') + browserMocks.webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: BROWSING_PAGE_ID, + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + webContentsId: id, + rendererWebContentsId: host.id + }) + return guest + } + + it('answers each page with the guest of its own half', () => { + const browsing = registerBrowsingGuest() + const { guest: document, browserPageId } = attachPreviewGuest() + + expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never) + expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never) + }) + + // Why this is the containment claim and not a lookup detail: page management, agent commands, + // download routing and certificate attribution all read the browsing map directly, so a + // document page being absent from it is what fences them without a guard of their own. + it('keeps a document page out of the browsing map entirely', () => { + registerBrowsingGuest() + const { browserPageId } = attachPreviewGuest() + + expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() + expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID]) + }) + }) + + it('attaches a guest once whatever profile it was asked for', () => { + const { guest } = attachPreviewGuest(303) + + browserManager.attachGuestPolicies(guest as never) + + expect(listenerCount(guest, 'dom-ready')).toBe(0) + expect(listenerCount(guest, 'will-navigate')).toBe(1) + }) +}) diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index c72936cf6a3..b7d3bbabe0a 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -882,34 +882,5 @@ describe('browserManager', () => { expect(debuggerAttach).toHaveBeenCalledWith('1.3') expect(debuggerSendCommand).not.toHaveBeenCalled() }) - - it('installs annotation viewport bridge in an isolated world', async () => { - const { guest } = makeGuest(4646) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-annotations', - webContentsId: guest.id as number, - rendererWebContentsId - }) - - const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', { - emitViewport: false, - enabled: true, - markers: [], - token: 'annotationviewporttoken' - }) - - expect(ok).toBe(true) - expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( - expect.any(Number), - [ - expect.objectContaining({ - code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') - }) - ], - false - ) - }) }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 7ac64b485b9..2b43e17a371 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -63,6 +63,11 @@ import { BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' +import { + getWorkspaceDocPageGuest, + installDocPreviewGuestPolicy, + isWorkspaceDocPageId +} from './doc-preview-guest-policy' import type { KeybindingOverrides } from '../../shared/keybindings' import { BrowserCertificateTrustController, @@ -157,6 +162,15 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } +/** + * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and + * anti-detection all apply. A workspace-document guest renders one granted document and gets none + * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + */ +export type BrowserGuestPolicy = + | { profile: 'browsing' } + | { profile: 'workspace-doc'; host: Electron.WebContents } +const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } type PendingMainFrameNavigation = { currentUrl: string supersededUrls: string[] @@ -671,12 +685,20 @@ export class BrowserManager { attachGuestPolicies( guest: Electron.WebContents, - inheritedOwnerContext: PopupOwnerContext | null = null + inheritedOwnerContext: PopupOwnerContext | null = null, + policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY ): void { if (this.policyAttachedGuestIds.has(guest.id)) { return } this.policyAttachedGuestIds.add(guest.id) + // Why one door with a profile rather than a second installer beside it: whether a guest was + // policy-attached at all is what registration and teardown both key on, so a guest that took + // another path into the app is invisible to both. + if (policy.profile === 'workspace-doc') { + this.attachWorkspaceDocGuestPolicies(guest, policy.host) + return + } if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } @@ -1016,6 +1038,30 @@ export class BrowserManager { }) } + /** + * A workspace document is not the web: no popups, no link routing, no anti-detection, and no + * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is + * this method's teardown, so a retired preview drops its listeners on the same path. + */ + private attachWorkspaceDocGuestPolicies( + guest: Electron.WebContents, + host: Electron.WebContents + ): void { + const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) + const handleDestroyed = (): void => { + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + this.policyCleanupByGuestId.set(guest.id, () => { + disposeDocPolicy() + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + }) + } + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, // not the request header, so the header-level Firefox switch in setupClientHintsOverride // must be matched here per navigation or the two layers disagree — itself a bot tell. @@ -1318,7 +1364,9 @@ export class BrowserManager { rendererWebContentsId }: BrowserGuestRegistration): boolean { const browserTabId = browserPageId ?? legacyBrowserTabId - if (!browserTabId) { + // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one + // id resolves in both and the tool door silently prefers the document guest over the page. + if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { return false } // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. @@ -1377,6 +1425,12 @@ export class BrowserManager { } unregisterGuest(browserTabId: string): void { + // Why the check on the exit door too: a document page withdraws by revoking its grant, never + // through here, so its id arriving is misaddressed — and the cancel below would evict that + // preview's live grab on the strength of it. + if (isWorkspaceDocPageId(browserTabId)) { + return + } // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. this.cancelGrabOp(browserTabId, 'evicted') @@ -1444,10 +1498,15 @@ export class BrowserManager { sessionProfileId?: string | null userAgentMode?: BrowserSessionUserAgentMode webContentsId: number - }): void { + }): boolean { + // Why the same check on both registration doors: one id resolving in both halves is the exact + // confusion the split registries exist to prevent. + if (isWorkspaceDocPageId(browserPageId)) { + return false + } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - return + return false } // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. this.offscreenGuestIds.add(webContentsId) @@ -1468,6 +1527,7 @@ export class BrowserManager { this.worktreeIdByTabId.set(browserPageId, worktreeId) } this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) + return true } unregisterAll(): void { @@ -1843,12 +1903,13 @@ export class BrowserManager { async setAnnotationViewportBridge( browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null ): Promise { const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() const next = prev .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options)) + .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) try { return await next @@ -1859,18 +1920,22 @@ export class BrowserManager { } } + // Why the caller resolves the guest: the same bridge serves browsing pages and workspace + // documents, which live in different halves of the page registry. + // Why a resolver and not the guest itself: this op may have waited behind another one, and a + // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — + // injecting into the guest the request named would bridge a page nobody is looking at. + // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and + // taking an id it cannot act on would invite the next reader to act on it. private async doSetAnnotationViewportBridgeImpl( - browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null ): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) + // Why no teardown here: the resolver already unregisters a page whose guest died, and the only + // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would + // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. + const guest = resolveGuest() if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) return false } @@ -1977,10 +2042,21 @@ export class BrowserManager { // --- Browser Context Grab — main-owned operations --- /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ + /** + * The guest a request from `senderWebContentsId` may act on, across both halves of the page + * registry. This is the only door taught about workspace-document guests: they are kept out of + * the browsing maps entirely, so page management, agent commands, download routing and + * certificate attribution all miss them without a guard of their own — and a reader who opens a + * tool on the document in front of them still gets an answer. + */ getAuthorizedGuest( browserTabId: string, senderWebContentsId: number ): Electron.WebContents | null { + const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) + if (docGuest) { + return docGuest + } const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { return null diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts new file mode 100644 index 00000000000..a3667154c9b --- /dev/null +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' + +const mocks = vi.hoisted(() => ({ + handleGuestWillDownload: vi.fn(), + noticeDocPreviewDownloadBlocked: vi.fn() +})) + +type WillDownloadListener = ( + event: { preventDefault: () => void }, + item: { id: string }, + webContents: { id: number } +) => void + +type FakeSession = { + listeners: WillDownloadListener[] + on: ReturnType + removeListener: ReturnType + getUserAgent: () => string + setUserAgent: ReturnType + setPermissionRequestHandler: ReturnType + setPermissionCheckHandler: ReturnType + setDisplayMediaRequestHandler: ReturnType +} + +const sessionsByPartition = new Map() + +function fakeSession(): FakeSession { + const listeners: WillDownloadListener[] = [] + return { + listeners, + on: vi.fn((event: string, listener: WillDownloadListener) => { + if (event === 'will-download') { + listeners.push(listener) + } + }), + removeListener: vi.fn((event: string, listener: WillDownloadListener) => { + if (event !== 'will-download') { + return + } + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } + }), + getUserAgent: () => 'Mozilla/5.0 Orca', + setUserAgent: vi.fn(), + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn() + } +} + +vi.mock('electron', () => ({ + session: { + fromPartition: (partition: string) => { + const existing = sessionsByPartition.get(partition) + if (existing) { + return existing + } + const created = fakeSession() + sessionsByPartition.set(partition, created) + return created + } + } +})) +vi.mock('./browser-manager', () => ({ + browserManager: { + handleGuestWillDownload: mocks.handleGuestWillDownload, + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn(), + notifyPermissionDenied: vi.fn() + } +})) +vi.mock('./doc-preview-download-block-notice', () => ({ + noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked +})) +vi.mock('./browser-media-access', () => ({ + hasSystemMediaAccess: () => false, + requestSystemMediaAccess: async () => false +})) +vi.mock('./browser-session-ua', () => ({ + cleanElectronUserAgent: (userAgent: string) => userAgent, + setupClientHintsOverride: vi.fn() +})) +vi.mock('./browser-session-user-agent-mode', () => ({ + setBrowserSessionUserAgentMode: vi.fn() +})) +vi.mock('./browser-webauthn-access', () => ({ + allowsBrowserWebAuthnPermission: () => false, + clearBrowserWebAuthnAccessHandlers: vi.fn(), + installBrowserWebAuthnAccessHandlers: vi.fn() +})) + +type PartitionPolicyInstaller = ( + profile: BrowserSessionProfile, + options?: { downloads?: 'route' | 'deny' } +) => void + +// Why imported per test rather than at the top: the installer remembers which partitions it has +// already configured in module state, so a shared import would make the second test's install a +// no-op and leave it reading the first test's listener. +async function loadInstaller(): Promise { + const module = await import('./browser-session-partition-policies') + return module.installBrowserSessionPartitionPolicies +} + +function profileFor(partition: string): BrowserSessionProfile { + return { + id: partition, + scope: 'isolated', + partition, + label: partition, + source: null, + userAgentMode: 'clean' + } +} + +/** Fires the partition's real `will-download` listener and reports what it decided. */ +function fireWillDownload(partition: string): { cancelled: boolean } { + const sess = sessionsByPartition.get(partition) + if (!sess || sess.listeners.length !== 1) { + throw new Error(`expected exactly one will-download listener on ${partition}`) + } + let cancelled = false + sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 }) + return { cancelled } +} + +beforeEach(() => { + vi.clearAllMocks() + sessionsByPartition.clear() + vi.resetModules() +}) + +describe('partition download policy', () => { + // The presence half: without it, a deny assertion passes for a partition that installed no + // listener at all, and would keep passing if the whole download path were removed. + it('routes a download on a partition that did not ask for the deny', async () => { + const install = await loadInstaller() + install(profileFor('persist:browsing-1')) + + expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) + expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith( + expect.objectContaining({ guestWebContentsId: 42 }) + ) + }) + + it('cancels a download on a partition that asked for the deny, routing nothing', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + + expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) + expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled() + }) + + // Why in the same run as the routing test above: a refusal the reader cannot see is a pressed + // button that does nothing, and a notice on the routing partition would announce a download that + // is about to arrive normally. + it('tells the reader about the refusal, and only on the partition that refused', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + install(profileFor('persist:browsing-1')) + + fireWillDownload('persist:browsing-1') + expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled() + + fireWillDownload('orca-doc-preview') + expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith( + expect.objectContaining({ id: 42 }) + ) + }) + + // Why both partitions in one run: the listener is module state shared across sessions, so a deny + // installed for one partition must not follow the next partition that installs after it. + it('keeps each partition on its own decision', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + install(profileFor('persist:browsing-1')) + + expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) + expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) + expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index 2118c62ed68..cf542665ae4 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -11,6 +11,7 @@ import { clearBrowserWebAuthnAccessHandlers, installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' +import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice' // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. const configuredPartitions = new Set() @@ -22,6 +23,23 @@ const handleWillDownload = ( browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } +/** + * Why a second listener instead of a branch inside the shared one: `will-download` is a session + * event that names no partition, so the only place the decision can be keyed by partition is which + * listener that partition's session got. A workspace-document guest has no page of its own to + * attribute a download to, so routing one lands it in this desktop's Downloads folder under a + * remote-authored name that nothing in the UI accounts for. + */ +const handleDeniedWillDownload = ( + event: Electron.Event, + _item: Electron.DownloadItem, + webContents: Electron.WebContents +): void => { + event.preventDefault() + // The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing. + noticeDocPreviewDownloadBlocked(webContents) +} + function resolvePermissionNoticeUrl( webContents: Electron.WebContents, details: Electron.PermissionRequest | undefined @@ -37,7 +55,13 @@ function resolvePermissionNoticeUrl( } } -export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void { +/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */ +export type BrowserPartitionDownloadPolicy = 'route' | 'deny' + +export function installBrowserSessionPartitionPolicies( + profile: BrowserSessionProfile, + options?: { downloads?: BrowserPartitionDownloadPolicy } +): void { const { partition } = profile const sess = session.fromPartition(partition) setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') @@ -106,7 +130,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr callback({ video: undefined, audio: undefined }) }) sess.removeListener('will-download', handleWillDownload) - sess.on('will-download', handleWillDownload) + sess.removeListener('will-download', handleDeniedWillDownload) + sess.on( + 'will-download', + options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload + ) configuredPartitions.add(partition) } @@ -115,6 +143,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se configuredPartitions.delete(partition) browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', handleWillDownload) + sess.removeListener('will-download', handleDeniedWillDownload) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) sess.setPermissionCheckHandler(null) diff --git a/src/main/browser/doc-preview-download-block-notice.test.ts b/src/main/browser/doc-preview-download-block-notice.test.ts new file mode 100644 index 00000000000..9fc4396b329 --- /dev/null +++ b/src/main/browser/doc-preview-download-block-notice.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + publishDocPreviewFailure: vi.fn(), + boundGrantIdByGuest: new Map() +})) + +vi.mock('./doc-preview-failure-notice', () => ({ + publishDocPreviewFailure: mocks.publishDocPreviewFailure +})) +vi.mock('./doc-preview-guest-policy', () => ({ + readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null +})) + +const GRANT_ID = 'a'.repeat(32) +const OTHER_GRANT_ID = 'b'.repeat(32) + +/** Only the identity matters: the module asks the guest registry what grant this contents holds. */ +function guestBoundTo(grantId: string | null): Electron.WebContents { + const guest = {} as Electron.WebContents + if (grantId !== null) { + mocks.boundGrantIdByGuest.set(guest, grantId) + } + return guest +} + +async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> { + const module = await import('./doc-preview-download-block-notice') + return module.noticeDocPreviewDownloadBlocked +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.boundGrantIdByGuest.clear() + // Why per test: the module remembers which grants it has already told the reader about. + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z')) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('noticeDocPreviewDownloadBlocked', () => { + it('tells the shell which preview had a download refused', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(GRANT_ID)) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({ + grantId: GRANT_ID, + reason: 'download-blocked' + }) + }) + + // Why: a document can ask in a loop. The reader learns nothing from the second notice, and every + // attempt would otherwise cross the IPC boundary and re-render the strip. + it('says it once however often the document asks', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + notice(guest) + vi.advanceTimersByTime(1_500) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1) + }) + + it('says it again for an attempt long after the last one', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + vi.advanceTimersByTime(2_500) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + }) + + // Why not one throttle for the whole app: two previews are two readers, and silencing the second + // because the first just refused something leaves that press unexplained. + it('throttles each preview on its own', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(GRANT_ID)) + notice(guestBoundTo(OTHER_GRANT_ID)) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({ + grantId: OTHER_GRANT_ID, + reason: 'download-blocked' + }) + }) + + // The absence half of the first test: no shell is showing this contents, so there is no preview + // to put a notice on. Without the presence tests above, this would pass on a module that never + // published anything at all. + it('says nothing for a contents no preview is bound to', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(null)) + + expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/doc-preview-download-block-notice.ts b/src/main/browser/doc-preview-download-block-notice.ts new file mode 100644 index 00000000000..2aefb2824a7 --- /dev/null +++ b/src/main/browser/doc-preview-download-block-notice.ts @@ -0,0 +1,34 @@ +import { publishDocPreviewFailure } from './doc-preview-failure-notice' +import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy' + +/** + * Why the reader is told at all, when the point of the fence is that nothing happens: a press that + * produces no file and no explanation reads as Orca being broken. The notice is chrome the document + * cannot see or read back, so the refusal stays as silent to the page as it was. + * + * Why a floor between notices: a document can ask in a loop, and every attempt would otherwise + * cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth. + */ +const NOTICE_MIN_INTERVAL_MS = 2_000 +const noticedAtByGrantId = new Map() + +export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void { + const grantId = readDocPreviewGuestBoundGrantId(guest) + // Nothing to route a notice to: no shell is showing this contents as a preview. + if (grantId === null) { + return + } + const now = Date.now() + // Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a + // grant that stops downloading stops being remembered. + for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) { + if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) { + noticedAtByGrantId.delete(noticedGrantId) + } + } + if (noticedAtByGrantId.has(grantId)) { + return + } + noticedAtByGrantId.set(grantId, now) + publishDocPreviewFailure({ grantId, reason: 'download-blocked' }) +} diff --git a/src/main/browser/doc-preview-failure-notice.test.ts b/src/main/browser/doc-preview-failure-notice.test.ts new file mode 100644 index 00000000000..2272e7bf614 --- /dev/null +++ b/src/main/browser/doc-preview-failure-notice.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme' +import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice' + +afterEach(() => { + setDocPreviewFailureSink(null) +}) + +describe('publishDocPreviewFailure', () => { + it('sends the grant, path, and reason on the failure channel', () => { + const send = vi.fn() + setDocPreviewFailureSink({ send }) + + publishDocPreviewFailure({ + grantId: 'a'.repeat(32), + relativePath: 'index.html', + reason: 'too-large' + }) + + expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, { + grantId: 'a'.repeat(32), + relativePath: 'index.html', + reason: 'too-large' + }) + }) + + // Why: reads can outlive the window that asked for them; a missing sink must not throw inside + // the protocol handler. + it('is a no-op with no sink registered', () => { + expect(() => + publishDocPreviewFailure({ + grantId: 'b'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' + }) + ).not.toThrow() + }) + + it('sends nothing into a destroyed window', () => { + const send = vi.fn() + setDocPreviewFailureSink({ send, isDestroyed: () => true }) + + publishDocPreviewFailure({ + grantId: 'c'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' + }) + + expect(send).not.toHaveBeenCalled() + }) + + // Why: a WebContents can be torn down between the liveness check and the send. + it('swallows a throwing sink and stops using it', () => { + const send = vi.fn(() => { + throw new Error('Object has been destroyed') + }) + setDocPreviewFailureSink({ send }) + const failure = { + grantId: 'd'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' as const + } + + expect(() => publishDocPreviewFailure(failure)).not.toThrow() + publishDocPreviewFailure(failure) + + expect(send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/doc-preview-failure-notice.ts b/src/main/browser/doc-preview-failure-notice.ts new file mode 100644 index 00000000000..1b7330d6f27 --- /dev/null +++ b/src/main/browser/doc-preview-failure-notice.ts @@ -0,0 +1,34 @@ +import { + DOC_PREVIEW_LOAD_FAILURE_CHANNEL, + type DocPreviewFailure +} from '../../shared/doc-preview-scheme' + +type DocPreviewFailureSink = { + send: (channel: string, payload: DocPreviewFailure) => void + isDestroyed?: () => boolean +} + +let failureSink: DocPreviewFailureSink | null = null + +export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void { + failureSink = sink +} + +/** + * The preview shell cannot read the guest's HTTP status, and a 4xx body renders as + * if it were the document. Pushing the reason lets the shell replace that with a + * localized notice for the failure the user actually hit. + */ +export function publishDocPreviewFailure(failure: DocPreviewFailure): void { + const sink = failureSink + // Why: a read can outlive the window that asked for it, and sending into torn-down + // WebContents throws — an unreadable asset must not take the protocol handler with it. + if (!sink || sink.isDestroyed?.()) { + return + } + try { + sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure) + } catch { + failureSink = null + } +} diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts new file mode 100644 index 00000000000..fc707279f8f --- /dev/null +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -0,0 +1,333 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + callRuntimeEnvironment: vi.fn(), + readFile: vi.fn(), + realpath: vi.fn(), + requireSshFilesystemProvider: vi.fn() +})) + +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: mocks.callRuntimeEnvironment +})) +vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: mocks.requireSshFilesystemProvider +})) + +import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' +import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' + +function sshGrant(): ReturnType { + return mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +function runtimeGrant(root = '/srv/repo/docs'): ReturnType { + return mintDocPreviewGrant({ + owner: { + kind: 'runtime', + environmentId: 'env-1', + worktreeSelector: 'id:wt-1', + worktreeRoot: '/srv/repo' + }, + root, + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +beforeEach(() => { + vi.clearAllMocks() + revokeAllDocPreviewGrants() + // Why: an unsymlinked host canonicalizes to the path it was given. + mocks.realpath.mockImplementation((path: string) => Promise.resolve(path)) + mocks.requireSshFilesystemProvider.mockReturnValue({ + readFile: mocks.readFile, + realpath: mocks.realpath + }) +}) + +describe('docPreviewContentType', () => { + it('maps document and asset extensions, defaulting to octet-stream', () => { + expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8') + expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8') + expect(docPreviewContentType('assets/logo.png')).toBe('image/png') + expect(docPreviewContentType('data.bin')).toBe('application/octet-stream') + }) +}) + +describe('readDocPreviewFile — ssh owner', () => { + it('reads text through the SSH filesystem provider', async () => { + mocks.readFile.mockResolvedValue({ content: '

hi

', isBinary: false }) + + const outcome = await readDocPreviewFile(sshGrant(), 'index.html') + + expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1') + expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html') + expect(outcome).toEqual({ + ok: true, + bytes: Buffer.from('

hi

', 'utf8'), + contentType: 'text/html; charset=utf-8' + }) + }) + + it('decodes a base64 binary asset', async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') + + expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) + }) + + // Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is + // always whole and needs no truncation flag. + it('serves a whole SSH read that carries no truncation flag', async () => { + mocks.readFile.mockResolvedValue({ content: '

whole

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) + }) + + // Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by + // design — the failure must name the file type, not a stale server. + it('reports a file type the host will not send as unsupported-asset', async () => { + mocks.readFile.mockResolvedValue({ content: '', isBinary: true }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2') + + expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' }) + }) + + // Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer — + // reporting it as a refused format would be a failure the workspace never reported. + it('serves an empty file the host still typed instead of calling it unsupported', async () => { + mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') + + expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' }) + }) + + // Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a + // symlink inside the grant would otherwise read anything the account can reach. + it('404s a path that canonicalizes outside the grant root', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path) + ) + + const outcome = await readDocPreviewFile(sshGrant(), 'escape.html') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.readFile).not.toHaveBeenCalled() + }) + + it('reads the canonical path once containment holds', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path) + ) + mocks.readFile.mockResolvedValue({ content: '

real

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true }) + expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html') + }) + + // Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to. + it('keeps serving a grant whose own root is a symlink', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs')) + ) + mocks.readFile.mockResolvedValue({ content: '

hi

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) + expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html') + }) + + it('404s when the host cannot canonicalize the path at all', async () => { + mocks.realpath.mockRejectedValue(new Error('no such file')) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ + ok: false, + status: 404 + }) + expect(mocks.readFile).not.toHaveBeenCalled() + }) + + it('404s a path outside the grant root without touching the provider', async () => { + const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() + }) + + it('reports an over-cap SSH file as too large rather than unreadable', async () => { + mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap')) + + expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({ + ok: false, + status: 413 + }) + }) + + it('404s when the provider read fails', async () => { + mocks.readFile.mockRejectedValue(new Error('no such file')) + + expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({ + ok: false, + status: 404 + }) + }) +}) + +describe('readDocPreviewFile — paired runtime owner', () => { + it('reads text over worktree-relative files.read', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

remote

', truncated: false, byteLength: 15 } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html') + + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'files.read', + { worktree: 'id:wt-1', relativePath: 'docs/index.html' }, + 15_000 + ) + expect(outcome).toEqual({ + ok: true, + bytes: Buffer.from('

remote

', 'utf8'), + contentType: 'text/html; charset=utf-8' + }) + }) + + it('falls back to the base64 preview RPC for a binary asset', async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: true, + result: { + content: png.toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png') + + expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith( + 2, + '/user-data', + 'env-1', + 'files.readPreview', + { worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' }, + 15_000 + ) + expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) + }) + + it('degrades on an old server whose empty binary preview carries no metadata', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({ + ok: false, + status: 415, + reason: 'unsupported-asset' + }) + }) + + it('serves an empty paired asset the host typed rather than reporting a refusal', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: true, + result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({ + ok: true, + bytes: Buffer.alloc(0), + contentType: 'image/png' + }) + }) + + // Why: files.read clamps text at the host cap and only says so in `truncated`; serving the + // clamped bytes renders a document that silently stops halfway. + it('refuses a truncated text read instead of serving the clamped bytes', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

half of', truncated: true, byteLength: 40_000_000 } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html') + + expect(outcome).toMatchObject({ ok: false, status: 413 }) + expect(outcome).not.toMatchObject({ ok: true }) + }) + + it('serves a read the host reports as complete', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

all

', truncated: false, byteLength: 12 } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true }) + }) + + // Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error. + it('reports the host rejecting an over-cap binary as too large', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'file_too_large' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({ + ok: false, + status: 413 + }) + }) + + it('does not treat an unrelated RPC failure as a binary fallback', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: false, + error: { code: 'runtime_error', message: 'permission_denied' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ + ok: false, + status: 404 + }) + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce() + }) + + it('404s a document outside the worktree, which files.read cannot address', async () => { + const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts new file mode 100644 index 00000000000..967461a91f9 --- /dev/null +++ b/src/main/browser/doc-preview-file-reader.ts @@ -0,0 +1,198 @@ +import { extname } from 'node:path' +import type { + RuntimeFilePreviewResult, + RuntimeFileReadResult +} from '../../shared/runtime-file-contracts' +import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' +import { getCanonicalUserDataPath } from '../persistence' +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + resolveCanonicalDocPreviewPath, + resolveDocPreviewTargetPath, + toRuntimeWorktreeRelativePath, + type DocPreviewGrant +} from './doc-preview-grant-registry' + +const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000 + +/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so + * a font is refused there by design, not by version. Name the file type, not the host's age. */ +const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.' + +/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would + * render a silently half-finished document. */ +const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.' + +/** The paired host rejects an over-cap asset outright instead of clamping it. */ +const RUNTIME_TOO_LARGE_ERROR = 'file_too_large' + +/** Both owners refuse an over-cap file; only their error shapes differ. */ +function isTooLargeReadError(error: unknown): boolean { + return ( + error instanceof FileReadCapExceededError || + (error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR) + ) +} + +export type DocPreviewReadOutcome = + | { ok: true; bytes: Buffer; contentType: string } + | { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string } + +const DOC_PREVIEW_CONTENT_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/plain; charset=utf-8', + '.csv': 'text/plain; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf' +} + +export function docPreviewContentType(relativePath: string): string { + return ( + DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream' + ) +} + +type PreviewFileBytes = { + content: string + isBinary: boolean + truncated?: boolean + /** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */ + mimeType?: string +} + +function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome { + if (source.truncated) { + return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE } + } + if (!source.isBinary) { + return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType } + } + if (source.content) { + return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType } + } + // Why: an empty binary body is two different answers. A host that still named the file's type + // read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined + // the format outright and has nothing to send. + return source.mimeType + ? { ok: true, bytes: Buffer.alloc(0), contentType } + : { + ok: false, + status: 415, + reason: 'unsupported-asset', + message: UNSERVABLE_ASSET_PREVIEW_MESSAGE + } +} + +async function readRuntimeDocPreviewFile( + environmentId: string, + worktreeSelector: string, + relativePath: string +): Promise { + const userDataPath = getCanonicalUserDataPath() + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'files.read', + { worktree: worktreeSelector, relativePath }, + DOC_PREVIEW_READ_TIMEOUT_MS + ) + if (response.ok) { + const result = response.result as RuntimeFileReadResult + return { content: result.content, isBinary: false, truncated: result.truncated === true } + } + // Why: files.read rejects binaries with a typed error; the base64 preview RPC serves + // images and fonts the same way it does for markdown previews. Match the exact + // message so an unrelated failure can't spoof the fallback. + if (response.error.message !== 'binary_file') { + throw new Error(response.error.message) + } + const previewResponse = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'files.readPreview', + { worktree: worktreeSelector, relativePath }, + DOC_PREVIEW_READ_TIMEOUT_MS + ) + if (!previewResponse.ok) { + throw new Error(previewResponse.error.message) + } + const preview = previewResponse.result as RuntimeFilePreviewResult + // Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent. + return { + content: preview.content, + isBinary: preview.isBinary, + ...(preview.mimeType ? { mimeType: preview.mimeType } : {}) + } +} + +function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome { + return { ok: false, status: 404, reason: 'unreadable', message } +} + +/** Reads one in-grant path over the same channel the editor uses for that owner. */ +export async function readDocPreviewFile( + grant: DocPreviewGrant, + relativePath: string +): Promise { + const absolutePath = resolveDocPreviewTargetPath(grant, relativePath) + if (!absolutePath) { + return notFoundOutcome() + } + const contentType = docPreviewContentType(relativePath) + try { + if (grant.owner.kind === 'ssh') { + const provider = requireSshFilesystemProvider(grant.owner.connectionId) + // Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink + // before the read — the lexical check above only proves the requested path looked contained. + const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) => + provider.realpath(path) + ) + if (!canonicalPath) { + return notFoundOutcome() + } + // Why: the SSH reader rejects an over-cap file outright, so its result is never partial. + return toOutcome(await provider.readFile(canonicalPath), contentType) + } + const worktreeRelativePath = toRuntimeWorktreeRelativePath( + grant.owner.worktreeRoot, + absolutePath + ) + if (!worktreeRelativePath) { + // Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel. + return notFoundOutcome() + } + // Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which + // canonicalizes and re-checks the worktree root server-side before reading. + return toOutcome( + await readRuntimeDocPreviewFile( + grant.owner.environmentId, + grant.owner.worktreeSelector, + worktreeRelativePath + ), + contentType + ) + } catch (error) { + return isTooLargeReadError(error) + ? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE } + : notFoundOutcome(error instanceof Error ? error.message : undefined) + } +} diff --git a/src/main/browser/doc-preview-grant-registry.test.ts b/src/main/browser/doc-preview-grant-registry.test.ts new file mode 100644 index 00000000000..8aadf9ed695 --- /dev/null +++ b/src/main/browser/doc-preview-grant-registry.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + getDocPreviewGrant, + mintDocPreviewGrant, + resolveCanonicalDocPreviewPath, + resolveDocPreviewTargetPath, + revokeAllDocPreviewGrants, + revokeDocPreviewGrant, + toRuntimeWorktreeRelativePath, + type DocPreviewGrant +} from './doc-preview-grant-registry' + +const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const + +function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant { + return mintDocPreviewGrant({ + owner: sshOwner, + root, + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +beforeEach(() => { + revokeAllDocPreviewGrants() +}) + +describe('doc preview grants', () => { + it('mints unguessable ids and looks them up', () => { + const first = mintPosixGrant() + const second = mintPosixGrant() + + expect(first.id).toMatch(/^[0-9a-f]{32}$/) + expect(first.id).not.toBe(second.id) + expect(getDocPreviewGrant(first.id)).toBe(first) + }) + + it('returns nothing for an unknown or revoked grant', () => { + const grant = mintPosixGrant() + + expect(getDocPreviewGrant('0'.repeat(32))).toBeNull() + expect(revokeDocPreviewGrant(grant.id)).toBe(true) + expect(getDocPreviewGrant(grant.id)).toBeNull() + expect(revokeDocPreviewGrant(grant.id)).toBe(false) + }) +}) + +describe('resolveDocPreviewTargetPath', () => { + it('resolves paths inside the grant root', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') + expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe( + '/srv/repo/docs/assets/logo.png' + ) + }) + + it('refuses parent traversal, absolute escapes and empty paths', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull() + }) + + it('refuses backslash and NUL segments that could re-split on the owning host', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull() + }) + + // Why this is a traversal test and not a containment test: every request path that names a + // sibling directory has to climb out of the root first, so the `..` segment guard answers it + // before the prefix check runs. Sibling containment is exercised where it is reachable — + // against a canonicalized path, below. + it('refuses a sibling directory by refusing the traversal that reaches it', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull() + }) + + it('keeps a Windows drive root addressable instead of turning it drive-relative', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + root: 'C:\\', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html') + }) + + it('follows the owning host path flavor rather than this process platform', () => { + const windowsGrant = mintDocPreviewGrant({ + owner: sshOwner, + root: 'C:\\srv\\repo\\docs', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe( + 'C:\\srv\\repo\\docs\\assets\\logo.png' + ) + expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull() + }) + + it('normalizes a trailing separator on the root', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + root: '/srv/repo/docs/', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') + expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() + }) +}) + +describe('resolveCanonicalDocPreviewPath', () => { + // Why here and not above: a canonical path is the one input that can name a sibling directory + // without traversing — the host resolved a symlink to it — so this is where the prefix check + // is the only thing standing between the grant and `/srv/repo/docs-private`. + it('refuses a canonical path in a sibling directory that shares the root prefix', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => + path === grant.root ? path : '/srv/repo/docs-private/secret.html' + ) + ).resolves.toBeNull() + }) + + it('answers the canonical path when it stays inside the canonical root', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path) + ).resolves.toBe('/srv/repo/docs/report.html') + }) +}) + +describe('toRuntimeWorktreeRelativePath', () => { + it('produces a worktree-relative path for files inside the worktree', () => { + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe( + 'docs/index.html' + ) + }) + + it('rejects paths outside the worktree, which files.read cannot address', () => { + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull() + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull() + }) + + it('uses Windows semantics for a Windows worktree root', () => { + expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe( + 'docs/index.html' + ) + expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull() + }) +}) diff --git a/src/main/browser/doc-preview-grant-registry.ts b/src/main/browser/doc-preview-grant-registry.ts new file mode 100644 index 00000000000..51237a685f1 --- /dev/null +++ b/src/main/browser/doc-preview-grant-registry.ts @@ -0,0 +1,208 @@ +import { randomBytes } from 'node:crypto' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +/** + * A preview grant is the only authority that turns an `orca-preview://` request + * into bytes: it names the host that owns the file and the single directory + * subtree requests may resolve inside. No grant, no bytes. + */ +export type DocPreviewOwner = + | { kind: 'ssh'; connectionId: string } + | { + kind: 'runtime' + environmentId: string + /** Selector the runtime resolves `files.read` against. */ + worktreeSelector: string + /** Worktree root on the runtime host; `files.read` only accepts paths inside it. */ + worktreeRoot: string + } + +export type DocPreviewGrant = { + id: string + owner: DocPreviewOwner + /** Containing directory of the opened document, on the owning host. */ + root: string + /** Path of the opened document relative to `root`. */ + entryRelativePath: string + /** + * Browser page the reader opened this document in. Main registers the guest under it once the + * guest commits to the grant, so the surface a tool names is the page the reader is looking at + * and not the grant, which a re-mint replaces underneath the same page. + */ + browserPageId: string +} + +const grantsById = new Map() + +function pathFlavorFor(root: string): typeof posix | typeof win32 { + return isWindowsAbsolutePathLike(root) ? win32 : posix +} + +function normalizeRootPath(root: string): string { + const flavor = pathFlavorFor(root) + const normalized = + flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root) + // Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the + // drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant. + if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) { + return normalized + } + // Why: a trailing separator would make the containment prefix check accept a sibling directory. + return normalized.length > 1 && normalized.endsWith(flavor.sep) + ? normalized.slice(0, -1) + : normalized +} + +export function mintDocPreviewGrant(params: { + owner: DocPreviewOwner + root: string + entryRelativePath: string + browserPageId: string +}): DocPreviewGrant { + const grant: DocPreviewGrant = { + id: randomBytes(16).toString('hex'), + owner: params.owner, + root: normalizeRootPath(params.root), + entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'), + browserPageId: params.browserPageId + } + grantsById.set(grant.id, grant) + return grant +} + +export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null { + return grantsById.get(grantId) ?? null +} + +/** + * Why anything listens at all: a grant is the only thing that names a preview's lifetime. State + * elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no + * other signal telling it the surface is gone, and would otherwise accrete one entry per grant + * for the life of the process. + */ +const revocationListeners = new Set<(grant: DocPreviewGrant) => void>() + +/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */ +export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void { + revocationListeners.add(listener) + return () => revocationListeners.delete(listener) +} + +function notifyRevoked(grant: DocPreviewGrant): void { + for (const listener of revocationListeners) { + listener(grant) + } +} + +export function revokeDocPreviewGrant(grantId: string): boolean { + canonicalRootByGrantId.delete(grantId) + const grant = grantsById.get(grantId) + if (!grant) { + return false + } + grantsById.delete(grantId) + notifyRevoked(grant) + return true +} + +export function revokeAllDocPreviewGrants(): void { + canonicalRootByGrantId.clear() + const revoked = [...grantsById.values()] + grantsById.clear() + for (const grant of revoked) { + notifyRevoked(grant) + } +} + +function hasUnsafeSegment(segments: string[]): boolean { + return segments.some( + (segment) => + segment.length === 0 || + segment === '.' || + segment === '..' || + segment.includes('\0') || + segment.includes('\\') + ) +} + +/** + * Resolves a request path to an absolute path on the owning host, or null when + * it would escape the grant's root. Path flavor follows the root (the owning + * host may be Windows while this client is not), never `process.platform`. + */ +export function resolveDocPreviewTargetPath( + grant: DocPreviewGrant, + relativePath: string +): string | null { + const segments = relativePath.split('/').filter((segment, index, all) => { + // Why: keep empty segments visible to the safety check except a single trailing one from `dir/`. + return !(segment === '' && index === all.length - 1) + }) + if (segments.length === 0 || hasUnsafeSegment(segments)) { + return null + } + const flavor = pathFlavorFor(grant.root) + const resolved = flavor.normalize(flavor.join(grant.root, ...segments)) + return isInsideRoot(grant.root, resolved, flavor) ? resolved : null +} + +function isInsideRoot( + root: string, + candidate: string, + flavor: typeof posix | typeof win32 +): boolean { + const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}` + return candidate.startsWith(rootPrefix) +} + +/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */ +const canonicalRootByGrantId = new Map>() + +/** + * Second containment pass for hosts where the lexical one is not enough: a symlink + * inside the root can point anywhere, and the SSH read RPC applies no root of its + * own. Both sides are canonicalized on the owning host before the prefix re-check; + * a host that cannot canonicalize a path answers nothing. + */ +export async function resolveCanonicalDocPreviewPath( + grant: DocPreviewGrant, + absolutePath: string, + realpath: (path: string) => Promise +): Promise { + try { + let canonicalRoot = canonicalRootByGrantId.get(grant.id) + if (!canonicalRoot) { + canonicalRoot = realpath(grant.root).then(normalizeRootPath) + canonicalRootByGrantId.set(grant.id, canonicalRoot) + } + const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)]) + const flavor = pathFlavorFor(root) + return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null + } catch { + // Why: a root that no longer canonicalizes must not fall back to the lexical answer. + canonicalRootByGrantId.delete(grant.id) + return null + } +} + +/** + * Path a runtime `files.read` can address, i.e. relative to the worktree root. + * Returns null when the grant root sits outside the worktree — the runtime file + * RPCs are worktree-scoped, so those documents are unreadable client-side. + */ +export function toRuntimeWorktreeRelativePath( + worktreeRoot: string, + absolutePath: string +): string | null { + const flavor = pathFlavorFor(worktreeRoot) + const normalizedRoot = normalizeRootPath(worktreeRoot) + const relative = flavor.relative(normalizedRoot, absolutePath) + if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) { + return null + } + if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) { + return null + } + return relative.replace(/\\/g, '/') +} diff --git a/src/main/browser/doc-preview-guest-policy.test.ts b/src/main/browser/doc-preview-guest-policy.test.ts new file mode 100644 index 00000000000..26b117c0fc0 --- /dev/null +++ b/src/main/browser/doc-preview-guest-policy.test.ts @@ -0,0 +1,550 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getWorkspaceDocPageGuest, + installDocPreviewGuestPolicy, + readDocPreviewGuestBoundGrantId, + reportDocPreviewLinkClick +} from './doc-preview-guest-policy' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' +import { + mintDocPreviewGrant, + revokeAllDocPreviewGrants, + revokeDocPreviewGrant +} from './doc-preview-grant-registry' + +type GuestHandlers = Record void> + +const HOST_RENDERER_ID = 42 + +function installOnFakeGuest( + hostId: number = HOST_RENDERER_ID, + /** What the guest is already showing when the embedder hands it over, as a real one usually is. */ + initialUrl = '' +): { + contents: object + handlers: GuestHandlers + hostId: number + isFocused: ReturnType + send: ReturnType + setWebRTCIPHandlingPolicy: ReturnType + windowOpenHandler: (details: { url: string }) => { action: string } + /** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */ + markContentsDestroyed: () => void +} { + const handlers: GuestHandlers = {} + let contentsDestroyed = false + const send = vi.fn() + const isFocused = vi.fn(() => true) + const setWebRTCIPHandlingPolicy = vi.fn() + let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({ + action: 'deny' + }) + const register = (event: string, handler: (...args: never[]) => void): void => { + handlers[event] = handler + } + const guest = { + isFocused, + isDestroyed: () => contentsDestroyed, + getURL: () => initialUrl, + on: vi.fn(register), + once: vi.fn(register), + setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => { + windowOpenHandler = handler + }), + setWebRTCIPHandlingPolicy + } + installDocPreviewGuestPolicy(guest as never, { id: hostId, send }) + return { + contents: guest, + handlers, + hostId, + isFocused, + send, + setWebRTCIPHandlingPolicy, + windowOpenHandler: (details) => windowOpenHandler(details), + markContentsDestroyed: () => { + contentsDestroyed = true + } + } +} + +type FakeGuest = ReturnType + +function startMainFrameNavigation(guest: FakeGuest, url: string): void { + guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never) +} + +/** A guest already showing a document, which is the only state a link can be pressed in. */ +function boundGuest(): { grant: ReturnType; guest: FakeGuest } { + const grant = mintGrant() + const guest = installOnFakeGuest() + startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html')) + return { grant, guest } +} + +function reportClick(guest: FakeGuest, url: string): void { + reportDocPreviewLinkClick(guest.contents as never, url) +} + +// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one +// would have the second silently replace the first rather than stand beside it. +let nextDocPageOrdinal = 0 + +function mintGrant(): ReturnType { + nextDocPageOrdinal += 1 + return mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId: `doc-page-${nextDocPageOrdinal}` + }) +} + +beforeEach(() => { + vi.clearAllMocks() + revokeAllDocPreviewGrants() +}) + +describe('doc preview guest policy', () => { + it('allows relative navigation within the bound grant', () => { + const { grant, guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'guide.html') as never + ) + + expect(preventDefault).not.toHaveBeenCalled() + expect(guest.send).not.toHaveBeenCalled() + }) + + // Why an unlatched guest is refused rather than trusted: the renderer-set src is + // browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is + // one the guest started for itself. + it('blocks a navigation the guest starts before a document has bound it', () => { + const grant = mintGrant() + const guest = installOnFakeGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'index.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('blocks navigation into a different live grant once bound', () => { + const { guest } = boundGuest() + const otherGrant = mintGrant() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(otherGrant.id, 'index.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + }) + + it('blocks a revoked grant even when it matches the bound id', () => { + const { grant, guest } = boundGuest() + revokeAllDocPreviewGrants() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'guide.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + }) + + it('blocks an external navigation without offering it to the renderer', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('blocks a file: navigation', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('applies the same rule to redirects', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('denies every popup and routes none of them', () => { + const { guest } = boundGuest() + + expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({ + action: 'deny' + }) + expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) + expect(guest.send).not.toHaveBeenCalled() + }) + + // Why: will-navigate never fires for a subframe, so an