refactor(pty): delete the SSH PTY provider and its registry slot (#27499)

* refactor(pty): delete the SSH PTY provider and its registry slot

Nothing in production registers an SSH PTY provider, so the registry's SSH
map was always empty: every SSH lookup answered undefined or threw the
'No PTY provider for connection' error. Delete the provider stack, its
consumer-session and source-recovery contracts, the registry maps and the
getSshProvider dependency, keeping each caller's existing result for an
absent provider. The registry now dispatches only 'local'; any other host
throws PtyHostNotDispatchableError, which keeps the renderer-matched
message for SSH ids and is never read as a gone or exited PTY.

* test(pty): keep the no-provider SSH routing cases in local-only form

* test: reword a comment naming the deleted SSH PTY provider

* test: drop an import the rebase left unused

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-11 09:23:28 -07:00
committed by GitHub
co-authored by m4air
parent 9c80a61199
commit a4a96fee70
152 changed files with 433 additions and 13435 deletions
+29 -127
View File
@@ -109,10 +109,10 @@
"invariant": "A prompt occurrence leaves the input region only when an accepted reply answers it (a current host's provider acknowledgment, or an older host's whole-write acceptance as before) or the user collapses it to a strip above the composer, which writes nothing and keeps the card mounted with its partial answer; status-backed occurrences are told apart by the host wait's start, so view toggles and remounts preserve them while real clear/replacement supersedes them, and a transcript-only one is dropped when the view stops observing it; obsolete results cannot change replacements; selector groups wait for complete writes before pacing; refusal or unknown handoff never causes replay; a card retires an unsubmitted composer send without writing under it or losing its text, and the hidden composer cannot send or interrupt; Collapse or Escape folds a card without writing, never during an in-flight answer, and a new occurrence shows expanded again; the phone blocks only Send while a card is expanded and shows one card per occurrence; cancellation stops unsent groups and active dictation can stop.",
"oracle": "After acknowledged phone replies with stale host status, the exact card disappears and Send is enabled; unknown, refused or unverifiable-settlement replies retain enabled choices, while an older host's accepted reply dismisses like an acknowledged one; a same-occurrence view toggle retains pending acknowledgment and real replacements discard it. A slow 32 KiB SSH answer completes all chunks before Enter; cancellation preserves issued bytes but omits remaining groups. Paired settlement invokes the actual provider and lost callbacks remain unknown; older hosts deliver ordinary messages, launch and selector groups once and their accepted answers dismiss as before. An answered card stays hidden across a view remount; a collapsed card is a strip that frees the composer and expands back; an identical question in a new wait shows again; a composer send caught by a new card keeps its bubble without submitting or clearing the line under it.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/runtime/runtime-terminal-verified-input.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2",
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/runtime/runtime-terminal-verified-input.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2",
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2",
"ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --maxWorkers=1",
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2",
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2",
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts --maxWorkers=2"
],
"testFiles": [
@@ -137,7 +137,6 @@
"src/main/ipc/pty/ipc/write-input-user-input.test.ts",
"src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts",
"src/main/ipc/pty-write-ipc-validation.test.ts",
"src/main/providers/ssh-pty-write.test.ts",
"src/main/daemon/daemon-pty-write-settlement-recovery.test.ts",
"src/main/runtime/runtime-terminal-writer.test.ts",
"src/main/runtime/rpc/terminal-send.test.ts",
@@ -241,7 +240,7 @@
"date": "2026-10-05",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2",
"result": "passed",
"durationSeconds": 232.36,
"summary": "257 tests passed across 19 explicit files, covering production cards, observed sends, existing launch/paste callers, IPC validation, SSH acknowledgment and daemon settlement recovery."
@@ -259,7 +258,7 @@
"date": "2026-10-06",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/runtime/runtime-terminal-verified-input.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/runtime/runtime-terminal-verified-input.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2",
"result": "passed",
"durationSeconds": 14.53,
"summary": "391 tests passed across 29 explicit files after the round-4 review fix, including older-host acceptance, the inert hidden composer, collapse that keeps a partial answer and returns focus to the composer, pane-scoped card focus, wait-start occurrence identity and the pane remount signal."
@@ -9949,7 +9948,7 @@
"oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, old daemon/relay capabilities, reused handles, stale incarnations, owner mismatch, and topology revision conflict; assert safe adoption or explicit failure without a second spawn or wrong-process attachment. Run fresh/resume with activate true/false against an isolated headed desktop host and a separate paired client, then against isolated headless serve: assert host presentation stays background, only the requesting viewer focuses the exact leaf, inactive calls preserve client/DOM focus, a same-version publication replay cannot lose focus intent, and sibling-first split publication cannot consume exact-leaf intent. Restore legacy split panes and groups beside a newer host-owned tab, preserving exact predecessor/new/successor order, output, input, resize, titles, tab/leaf identity, active group, and multi-client convergence. For completion, drive a known running agent through outer transport loss, authoritative provider rejection, reconnect, explicit stop, real exit status, and successful hook completion; assert unavailable evidence never dispatches completion and two fresh authoritative idle samples are required after the gap. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, exact tokened fixture PIDs are dead, and unrelated tabs/processes survive until scoped cleanup.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/agent-session.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/web-session-intent-owner.test.ts src/renderer/src/runtime/remote-server-parity.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
"pnpm test:repro:remote-agent-session",
"pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
@@ -9976,7 +9975,6 @@
"src/renderer/src/lib/codex-session-restart.test.ts",
"src/shared/claimed-agent-pty-owner.test.ts",
"src/main/daemon/daemon-pty-adapter.test.ts",
"src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts",
"src/main/runtime/orca-runtime-agent-session-operation.test.ts",
"src/main/runtime/remote-agent-session-host-authority.integration.test.ts",
"src/main/runtime/rpc/methods/agent-session.test.ts",
@@ -10213,7 +10211,7 @@
"date": "2026-07-21",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"result": "passed",
"durationSeconds": 7.67,
"summary": "Ten focused files and 325 tests passed after the final review fixes, covering claim scope, mixed-version Pi/SSH fallback ordering, operation replay, terminal retirement, causal inventory fencing, exact concurrent handoff confirmation, daemon-generation integration, transport behavior, and remote host integration."
@@ -10653,7 +10651,7 @@
"invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.",
"oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, and window wake. It asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, visibility resume reasserts real drift without hot listSessions, one settled wake produces one geometry-only readback, and SSH relay readback is authoritative with bounded fallback. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/runtime/orca-runtime.test.ts"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts",
@@ -10661,7 +10659,6 @@
"src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts",
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
"src/main/ipc/pty-applied-size-reporting.test.ts",
"src/main/providers/ssh-pty-provider.test.ts",
"src/main/runtime/orca-runtime.test.ts"
],
"assertionRefs": [
@@ -10703,13 +10700,6 @@
"provider-owned null remains unverified instead of falling back to requested size"
]
},
{
"file": "src/main/providers/ssh-pty-provider.test.ts",
"assertions": [
"SSH applied-size readback uses the relay with a one-second timeout",
"old-relay method-not-found is cached while transient failures remain retryable"
]
},
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
@@ -10723,7 +10713,7 @@
"date": "2026-07-21",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/runtime/orca-runtime.test.ts",
"result": "passed",
"durationSeconds": 18.54,
"summary": "8 test files passed, 1,272 tests passed on the PR branch."
@@ -12409,18 +12399,15 @@
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-pending-projection-admissions.test.ts src/main/ipc/ssh-pty-legacy-projection.test.ts src/main/ipc/ssh-pty-model-admission.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/wsl-guest/protocol-backpressure.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/wsl-guest/protocol-backpressure.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-model-admission.test.ts src/main/ipc/ssh-pty-output-model-migration.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/wsl-guest/dispatcher.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-legacy-projection.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-generation-guard.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot",
"pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-generation-guard.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/persistence-ssh-targets-and-pane-keys.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts --reporter=dot"
// Historical evidence commands; the override is ignored after the always-on transition.
],
@@ -12434,15 +12421,8 @@
"src/main/runtime/orca-runtime-path-candidate-history.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/orca-runtime-tail-wait-memo.test.ts",
"src/main/providers/ssh-pty-provider.test.ts",
"src/main/providers/ssh-pty-notification-routing.test.ts",
"src/main/providers/ssh-pty-source-delivery-ledger.test.ts",
"src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts",
"src/main/providers/ssh-pty-provider-exit-race.test.ts",
"src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts",
"src/wsl-guest/protocol-backpressure.test.ts",
"src/wsl-guest/dispatcher.test.ts",
"src/shared/pty-consumer-session.test.ts",
"src/main/ipc/ssh-pty-model-admission.test.ts",
"src/main/ipc/ssh-pty-output-intake.test.ts",
"src/main/ipc/ssh-pty-output-model-migration.test.ts",
@@ -12515,42 +12495,6 @@
"resolves tui-idle when a stale Codex prompt is followed by the ready header"
]
},
{
"file": "src/main/providers/ssh-pty-notification-routing.test.ts",
"assertions": [
"provisional source frames remain unprojected and are discarded on rollback",
"held and later recovery frames route only to the private sink until final commit, without ordinary listeners or live PTY publication",
"exact source frames use their immutable incarnation without invoking or mutating the legacy incarnation resolver",
"exit during private recovery retires the activation ledger at final commit and rejects late same-token frames",
"private recovery retirement restores the exact predecessor without issuing a second cancellation",
"older rollback cancels only its exact token without replacing a newer activation"
]
},
{
"file": "src/main/providers/ssh-pty-source-delivery-ledger.test.ts",
"assertions": [
"exit seals a provisional or private-recovery lease against later same-token admission",
"a stale transfer retains rollback authority and requests exact cancellation instead of orphaning its token"
]
},
{
"file": "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts",
"assertions": [
"same-decoder-turn source data waits for claim validation, failed claim rollback, and exact cancellation proof while a newer activation remains live"
]
},
{
"file": "src/main/providers/ssh-pty-provider-exit-race.test.ts",
"assertions": [
"exit before a fresh spawn reply drops provisional source data and settles exact cancellation"
]
},
{
"file": "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts",
"assertions": [
"source-credit restore-required generic reattach fails as expired instead of returning an outputless live PTY"
]
},
{
"file": "src/main/persistence-ssh-targets-and-pane-keys.test.ts",
"assertions": [
@@ -12642,12 +12586,6 @@
"a parsed stale-generation ACK cannot release in-flight byte credit or flush queued output"
]
},
{
"file": "src/shared/pty-consumer-session.test.ts",
"assertions": [
"mismatched recovery is typed for one-shot fallback while the stale principal remains a subscriber and the retained principal and lease preserve recovery authority"
]
},
{
"file": "src/main/ssh/ssh-multiplexer-transport-writer.test.ts",
"assertions": [
@@ -12718,7 +12656,7 @@
"date": "2026-07-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/wsl-guest/protocol-backpressure.test.ts --reporter=dot",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/wsl-guest/protocol-backpressure.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 2.24,
"summary": "Sixteen deterministic SSH relay/session/source/decoder files passed 192 tests, including exit-sealed private recovery, retained stale-transfer cancellation authority, exact private-frame proof watermarks, one retirement record across 10,000 same-PTY token rotations, stale-owner fallback, and scheduler rejection isolation; no live topology was exercised."
@@ -12736,28 +12674,10 @@
"date": "2026-07-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 6.89,
"summary": "Thirteen current-head deterministic files passed 150 tests across exit-sealed private recovery, stale-transfer cancellation authority, bounded latest-token retirement, restore retirement, fail-closed recovery cancellation, partial ACK and token rotation, reclaimed-span replacement, mux lane fairness, provisional activation, and exit-proof ordering; no live topology was exercised."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 1.86,
"summary": "Seven current-worktree provider/session files passed 94 tests after a red deterministic seam reproduced completion visibility before lease-held recovery data; the candidate transfers held and later frames only into private recovery quarantine, final-commits after fence/admission, retires exited activation state, and preserves fail-closed malformed, gapped, overlapping, missing-body, cancellation-proof, and replacement behavior."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 1.63,
"summary": "Five deterministic provider, relay, deploy, and session files passed 70 tests after red seams proved that source frames mutated legacy incarnation state and generic restore-required reattach returned an outputless live PTY. The candidate keeps exact source identity side-effect free, fails generic restore-required reattach as expired, offers V1 through reconnect, and proves a failed recovery activation stays private and exact-retryable until successful response settlement; no live topology was exercised."
}
],
"runtimeBudget": {
@@ -13028,7 +12948,7 @@
"invariant": "PTY snapshot capability discovery must never synchronously block renderer JavaScript. Restored daemon capability must be known before workspace readiness enables cold activation; unknown or legacy capability must remain eager. A healthy SSH provider must return definitive false without polling. One unresponsive capability batch must fail open within one second regardless of PTY count, and stale async responses must not update current bindings.",
"oracle": "The preload test rejects sendSync and requires ipcRenderer.invoke. Unit contracts assert 512-ID batching, one-second fail-open, unknown retry, definitive SSH false, and generation-fenced stale responses. During an injected 1.5-second Electron main-thread stall, a renderer-owned 50ms interval must keep a maximum gap below 500ms and each API call must return within 100ms. The production cold-activation journey must still mount at most three of eight daemon tabs after reload, while Docker SSH restoration remains eager.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/preload/pty-snapshot-capability-ipc.test.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/main/providers/ssh-pty-provider.test.ts src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/preload/pty-snapshot-capability-ipc.test.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts --reporter=dot",
"pnpm exec electron-vite build --mode e2e",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/pty-snapshot-capability-main-stall.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
@@ -13036,7 +12956,6 @@
"testFiles": [
"src/preload/pty-snapshot-capability-ipc.test.ts",
"src/main/ipc/pty-startup-barrier-and-listing.test.ts",
"src/main/providers/ssh-pty-provider.test.ts",
"src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts",
"src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx",
"src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
@@ -13144,7 +13063,6 @@
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. Successful current-transport recovery must clear every stale error surfaced for that pane without clearing or displaying a sibling pane's error; a later genuine failure must remain visible. Each transport retains at most eight suppressed messages, each pane retains at most eight distinct messages, and the rendered surface retains at most 24 lines and 4,000 characters. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle. Reconnect must alternate exact activation with authoritative inventory so neither a stale activation response nor an activation failure can strand or retire a pane, and activating a parked surface whose persisted binding was already retired must respawn it rather than report a changed owner after signalling its exit.",
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination. A focused recovery oracle surfaces two distinct failures through one transport, completes authoritative replay, and requires both matching clear callbacks; pane-state tests require only the active pane's error to render, matching-pane recovery to preserve sibling and unrelated errors, dismissal to re-admit later failures, and distinct storms to retain only the newest eight messages within 24 lines and 4,000 characters.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts",
@@ -13158,7 +13076,6 @@
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts",
"src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts",
"src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts",
"src/renderer/src/startup/ssh-startup-reconnect.test.ts",
@@ -13388,12 +13305,6 @@
"a detached null-PTY leaf remains marked for pending activation before ownership transfer"
]
},
{
"file": "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts",
"assertions": [
"a successful session-ID reattach remembers its returned incarnation before a later legacy exit is published"
]
},
{
"file": "src/main/ipc/repos-remote.test.ts",
"assertions": [
@@ -15207,7 +15118,7 @@
"oracle": "Seed status, dispatch, and worker_done rows across direct-handle and canonical Run recipients in an isolated DB. Compare pointer count, RPC and built-CLI check output, direct SQLite rows, unread/peek/all/type filters, concurrent pollers, fixed Delivery IDs, explicit acknowledgment, restart, filtered check --wait, and coordinator remint. Route a 125-row old-handle backlog, inject a commit without notification, and require startup repair. Exercise duplicate Run/Dispatch owners, stale panes, 50-row pages, cancellation, lifecycle fencing, and absent PTYs. Drop a federation ACK, reconnect/restart v1/v2 peers, and require stable import plus no duplicate read-row wake. Hold a healthy SSH write past five seconds but below the 60-second settlement deadline, then distinguish the three settlement outcomes end to end: only a proven refusal releases the reservation and drains a delivery parked behind the watermark; a dropped in-flight settlement must surface as unverifiable with bytes handed to the transport, preserve the durable write-attempted reservation, and emit no duplicate pointer after restart; a settled write that throws mid-pointer is unverifiable, not a refusal; and an Enter whose settlement is lost stays at enter-attempted so restart emits no second Enter. Install the production PTY controller and verify that it routes settled writes through the owning provider and refuses before any byte when the routed provider cannot settle. Census every production PTY provider class and reject a settlement synthesized from the fire-and-forget write.",
"commands": [
"pnpm run build:cli && pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-message-delivery-identity.test.ts --reporter=dot --testTimeout=5000",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot"
],
@@ -15222,8 +15133,6 @@
"src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts",
"src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts",
"src/main/runtime/orchestration/formatter.test.ts",
"src/main/providers/ssh-pty-provider.test.ts",
"src/main/providers/ssh-pty-write.test.ts",
"src/main/runtime/orchestration/mailbox-pointer-stage.test.ts",
"src/main/daemon/client.test.ts",
"src/main/daemon/daemon-pty-router.test.ts",
@@ -15364,7 +15273,7 @@
"date": "2026-09-05",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
"result": "passed",
"durationSeconds": 4.73,
"summary": "267 tests passed after the pointer-write path moved to the three-valued WriteSettlement union. New coverage: a dropped in-flight SSH settlement reaches the stager as unverifiable with bytes handed to the transport, a settled write that throws mid-pointer preserves the write-attempted reservation, an Enter whose settlement is lost stays at enter-attempted with no second Enter after restart, a refusal releases the reservation and drains a delivery parked behind its watermark, the production controller refuses before any byte when the routed provider cannot settle, and a census pins the five production IPtyProvider classes and rejects a settlement synthesized from the fire-and-forget write. Each new assertion was verified red against the pre-fix shape."
@@ -15373,7 +15282,7 @@
"date": "2026-08-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
"result": "passed",
"durationSeconds": 8.22,
"summary": "245 tests passed across mailbox identity, durable coordinator-handle migration, insertion-time canonicalization, duplicate-free 51-row ownership branch caps, unrestricted reservation merging, direct and Dispatch pointer suppression, persisted reconciliation, 50-row paging and filtered waits, cross-PTY serialization, lifecycle fencing, bounded daemon and SSH transport settlement, outstanding Deliveries, reminted Dispatch ownership, acknowledgment, cancellation, and bounded pane lookup."
@@ -18113,12 +18022,12 @@
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/files-watch-cleanup.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts",
"pnpm run build:electron-vite",
"node config/scripts/runtime-file-watcher-resource-probe.mjs",
@@ -18164,7 +18073,6 @@
"src/main/runtime/rpc/methods/files.test.ts",
"src/main/runtime/rpc/methods/files-watch-cancellation.test.ts",
"src/main/runtime/rpc/methods/files-watch-cleanup.test.ts",
"src/main/providers/ssh-pty-provider.test.ts",
"src/renderer/src/runtime/runtime-file-client.test.ts",
"src/renderer/src/web/web-runtime-client.test.ts",
"src/shared/cross-platform-path.test.ts",
@@ -18471,12 +18379,6 @@
"a failed paired-web unwatch retries after already-resolved or late physical exit and emits stream end exactly once"
]
},
{
"file": "src/main/providers/ssh-pty-provider.test.ts",
"assertions": [
"remote process enumeration preserves authoritative worktree ownership while scoping relay PTY ids to the SSH connection"
]
},
{
"file": "src/shared/cross-platform-path.test.ts",
"assertions": [
@@ -18532,7 +18434,7 @@
"date": "2026-07-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 11.98,
"summary": "Thirty-nine watcher, cancellation, desktop/serve, paired-web, WSL, SSH/relay, removal, and PTY physical-exit files passed with 2,008 tests. Capacity, quarantine, runtime-root, relay, and SSH setup layers each prove that 10,000 aborted callers detach while one anchor remains and zero waiters remain after settlement."
@@ -18541,7 +18443,7 @@
"date": "2026-07-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 11.59,
"summary": "Thirty-four watcher, cancellation, desktop/serve, paired-web, WSL, removal, PTY physical-exit, provider, renderer, and path files passed with 1,999 tests; paired-web cancellation now owns a server token before ready and aborts pending setup without a late native install."
@@ -18559,7 +18461,7 @@
"date": "2026-07-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 10.01,
"summary": "Twenty-seven watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,886 tests. The suite includes nested removal fencing, renderer/runtime cwd-only admission, legacy provider rows, rejected kill retries, transient WSL capacity, and synchronous-startup reservation cleanup."
@@ -18613,7 +18515,7 @@
"date": "2026-07-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts",
"result": "passed",
"durationSeconds": 9.12,
"summary": "Twenty-five watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,762 tests. The suite covers operations joining in-flight termination, kill failures retaining ownership, bounded timeouts, atomic relay spawn/revive caps, and PTY-removal admission."
@@ -18667,7 +18569,7 @@
"date": "2026-07-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts",
"result": "passed",
"durationSeconds": 9.03,
"summary": "Twenty-three watcher, desktop/serve removal, terminal-spawn gate, daemon/SSH physical PTY exit, provider ownership, RPC, renderer, and cross-platform path files passed with 1,630 tests. The suite includes same-turn watcher replacement, authoritative SSH teardown before Git, bounded kill-without-exit failures that retain ownership, and local/SSH deletion fail-closed behavior."
-20
View File
@@ -3919,24 +3919,6 @@
"src/main/providers/ssh-git-provider-upstream-lease.test.ts": 110,
"src/main/providers/ssh-git-provider-worktree.test.ts": 149,
"src/main/providers/ssh-git-worktree-list-dedupe.test.ts": 125,
"src/main/providers/ssh-pty-inspect-observation-identity.test.ts": 59,
"src/main/providers/ssh-pty-live-source-restore-respawn.test.ts": 434,
"src/main/providers/ssh-pty-notification-recovery-activation.test.ts": 66,
"src/main/providers/ssh-pty-notification-rejection-routing.test.ts": 210,
"src/main/providers/ssh-pty-notification-routing.test.ts": 90,
"src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts": 446,
"src/main/providers/ssh-pty-provider-claim-incarnation.test.ts": 435,
"src/main/providers/ssh-pty-provider-exit-race.test.ts": 509,
"src/main/providers/ssh-pty-provider-process-events.test.ts": 474,
"src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts": 529,
"src/main/providers/ssh-pty-provider-spawn.test.ts": 499,
"src/main/providers/ssh-pty-provider-terminal-repair.test.ts": 455,
"src/main/providers/ssh-pty-provider.test.ts": 470,
"src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts": 249,
"src/main/providers/ssh-pty-relay-absence-verdict.test.ts": 430,
"src/main/providers/ssh-pty-source-delivery-ledger.test.ts": 58,
"src/main/providers/ssh-pty-spawn-env.test.ts": 49,
"src/main/providers/ssh-pty-write.test.ts": 473,
"src/main/providers/ssh-worktree-catalog-authority.test.ts": 1068,
"src/main/providers/stable-foreground-process.test.ts": 68,
"src/main/providers/startup-line-typed-length.live-shell.test.ts": 165,
@@ -4053,7 +4035,6 @@
"src/main/rate-limits/service-zcode-usage.test.ts": 322,
"src/main/rate-limits/zcode-usage-fetcher.test.ts": 77,
"src/main/refused-tree-kill-root-termination.test.ts": 293,
"src/main/remote-worktree-history-cleanup.test.ts": 44,
"src/main/repo-git-remote-avatar-refresh.test.ts": 135,
"src/main/repo-git-remote-identity-enrichment.test.ts": 119,
"src/main/repo-git-remote-identity.test.ts": 402,
@@ -11274,7 +11255,6 @@
"src/shared/project-identity-succession.test.ts": 81,
"src/shared/promise-settlement-waiters.test.ts": 169,
"src/shared/protocol-compat.test.ts": 88,
"src/shared/pty-consumer-session.test.ts": 77,
"src/shared/pty-delivery-diagnostics.test.ts": 55,
"src/shared/pty-liveness-verdict.test.ts": 46,
"src/shared/pty-owner-backend.test.ts": 45,
@@ -1,8 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { registerSshPtyProvider, getLocalPtyProvider } from './pty'
import { installPtyInspectIpcHandlers } from './pty/ipc/inspect'
import { ptyOwnership } from './pty/provider/ownership-state'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -54,48 +52,14 @@ describe('scoped activation PTY inventory', () => {
function install() {
const localList = vi.fn(async () => [{ id: 'local', cwd: '/', title: 'shell' }])
installDaemonTestProvider({ listProcesses: localList })
const remoteLists = Array.from({ length: 50 }, (_, index) => {
const list = vi.fn(async () => [
{
id: `ssh:host-${index}@@pty-1`,
cwd: '/remote',
title: 'agent',
worktreeId: 'repo::/remote'
}
])
registerSshPtyProvider(`host-${index}`, {
...getLocalPtyProvider(),
listProcesses: list,
providesAgentSessionOwnerListings: () => true
})
return list
})
const startup = vi.fn(async () => {})
installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise: startup })
const list = (scope?: unknown) => handlers.get('pty:listSessions')!(null, scope)
return { localList, remoteLists, startup, list }
return { localList, startup, list }
}
it('queries only the chosen SSH provider and preserves workspace and ownership evidence', async () => {
const { list, localList, remoteLists, startup } = install()
expect(await list({ connectionId: 'host-17' })).toEqual([
{
id: 'ssh:host-17@@pty-1',
cwd: '/remote',
title: 'agent',
worktreeId: 'repo::/remote',
agentOwnership: 'absent'
}
])
expect(remoteLists[17]).toHaveBeenCalledOnce()
expect(remoteLists.reduce((count, mock) => count + mock.mock.calls.length, 0)).toBe(1)
expect(localList).not.toHaveBeenCalled()
expect(startup).not.toHaveBeenCalled()
expect(ptyOwnership.get('ssh:host-17@@pty-1')).toBe('ssh:host-17')
})
it('waits for local startup and never visits remote providers for a local scope', async () => {
const { list, localList, remoteLists, startup } = install()
it('waits for local startup for a local scope', async () => {
const { list, localList, startup } = install()
let release!: () => void
startup.mockImplementation(
() =>
@@ -108,32 +72,26 @@ describe('scoped activation PTY inventory', () => {
release()
await pending
expect(localList).toHaveBeenCalledOnce()
expect(remoteLists.every((mock) => mock.mock.calls.length === 0)).toBe(true)
})
it('propagates selected-host failure and never substitutes the local inventory', async () => {
const { list, localList, remoteLists } = install()
remoteLists[3].mockRejectedValue(new Error('relay unavailable'))
await expect(list({ connectionId: 'host-3' })).rejects.toThrow('relay unavailable')
await expect(list({ connectionId: 'missing' })).rejects.toThrow('No PTY provider')
it('fails an SSH scope and never substitutes the local inventory', async () => {
const { list, localList } = install()
await expect(list({ connectionId: 'host-3' })).rejects.toThrow('No PTY provider')
expect(localList).not.toHaveBeenCalled()
})
it.each([null, {}, { connectionId: '' }, { connectionId: 42 }])(
'rejects malformed scope %j before inventory admission',
async (scope) => {
const { list, localList, remoteLists } = install()
const { list, localList } = install()
await expect(list(scope)).rejects.toThrow('invalid_pty_session_list_scope')
expect(localList).not.toHaveBeenCalled()
expect(remoteLists.every((mock) => mock.mock.calls.length === 0)).toBe(true)
}
)
it('preserves unscoped diagnostic inventory and its remote-error fallback', async () => {
const { list, localList, remoteLists } = install()
remoteLists[3].mockRejectedValue(new Error('relay unavailable'))
expect(await list()).toHaveLength(50)
it('lists only the local inventory without a scope', async () => {
const { list, localList } = install()
expect(await list()).toHaveLength(1)
expect(localList).toHaveBeenCalledOnce()
expect(remoteLists.every((mock) => mock.mock.calls.length === 1)).toBe(true)
})
})
@@ -1,13 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import {
registerSshPtyProvider,
clearPtyOwnershipForConnection,
clearProviderPtyState,
setLocalPtyProvider,
unregisterSshPtyProvider
} from './pty'
import { clearProviderPtyState, setLocalPtyProvider } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -61,182 +55,6 @@ describe('registerPtyHandlers', () => {
registerAgentClaimController
} = setupPtyIpcSuite()
it('fails closed without spawning when a recovered owner provider disconnects', async () => {
const connectionId = 'ssh-agent-owner-gone'
const ownerPtyId = `ssh:${connectionId}@@relay-owner`
const claim = {
...recoveredAgentClaim,
identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc'
}
const owner: AgentSessionOwnerBinding = {
claim,
generation: 'generation-remote',
phase: 'live',
ptyId: ownerPtyId,
surface: recoveredAgentSurface
}
const remoteProvider = createAgentClaimProvider({
sessions: [
{
id: ownerPtyId,
incarnationId: 'incarnation-remote',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [owner]
}
],
livePtyIds: new Set([ownerPtyId])
})
registerSshPtyProvider(connectionId, remoteProvider as never)
setLocalPtyProvider(createAgentClaimProvider({}) as never)
const controller = registerAgentClaimController()
await expect(
controller.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
})
).resolves.toMatchObject({ id: ownerPtyId })
unregisterSshPtyProvider(connectionId)
const localSpawn = vi.fn(async () => ({ id: 'must-not-spawn' }))
setLocalPtyProvider(createAgentClaimProvider({ spawn: localSpawn }) as never)
await expect(
controller.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
})
).rejects.toThrow('execution_owner_unavailable')
expect(localSpawn).not.toHaveBeenCalled()
clearProviderPtyState(ownerPtyId)
})
it('fails closed when provider listings disagree about a recovered claim owner', async () => {
const claim = {
...recoveredAgentClaim,
identityDigest: 'ddddddddddddddddddddddddddddddddddddddddddd'
}
const localOwner: AgentSessionOwnerBinding = {
claim,
generation: 'generation-conflict',
phase: 'live',
ptyId: 'pty-conflict-local',
surface: recoveredAgentSurface
}
const remoteOwner: AgentSessionOwnerBinding = {
...localOwner,
ptyId: 'ssh:ssh-agent-conflict@@pty-conflict-remote'
}
const localSpawn = vi.fn(async () => ({ id: 'must-not-spawn' }))
setLocalPtyProvider(
createAgentClaimProvider({
sessions: [
{
id: localOwner.ptyId,
incarnationId: 'incarnation-conflict-local',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [localOwner]
}
],
spawn: localSpawn
}) as never
)
registerSshPtyProvider(
'ssh-agent-conflict',
createAgentClaimProvider({
sessions: [
{
id: remoteOwner.ptyId,
incarnationId: 'incarnation-conflict-remote',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [remoteOwner]
}
]
}) as never
)
const controller = registerAgentClaimController()
await expect(
controller.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
})
).rejects.toThrow('agent_session_conflict')
expect(localSpawn).not.toHaveBeenCalled()
unregisterSshPtyProvider('ssh-agent-conflict')
clearProviderPtyState(localOwner.ptyId)
clearProviderPtyState(remoteOwner.ptyId)
})
it('converges after conflicting listings shrink to one exact owner', async () => {
const claim = {
...recoveredAgentClaim,
identityDigest: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
}
const ownerA: AgentSessionOwnerBinding = {
claim,
generation: 'generation-a',
phase: 'live',
ptyId: 'pty-conflict-a',
surface: recoveredAgentSurface
}
const ownerB: AgentSessionOwnerBinding = {
...ownerA,
generation: 'generation-b',
ptyId: 'ssh:ssh-agent-converge@@pty-conflict-b'
}
const localSessions = [
{
id: ownerA.ptyId,
incarnationId: 'incarnation-conflict-a',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [ownerA]
}
]
const remoteSessions = [
{
id: ownerB.ptyId,
incarnationId: 'incarnation-conflict-b',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [ownerB]
}
]
const local = createAgentClaimProvider({ sessions: localSessions })
setLocalPtyProvider(local as never)
registerSshPtyProvider(
'ssh-agent-converge',
createAgentClaimProvider({ sessions: remoteSessions }) as never
)
const controller = registerAgentClaimController()
const request = {
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
}
await expect(controller.spawn(request)).rejects.toThrow('agent_session_conflict')
localSessions.splice(0)
await expect(controller.spawn(request)).resolves.toMatchObject({
id: ownerB.ptyId,
agentSessionEnsure: { disposition: 'adopted', owner: ownerB }
})
expect(local.spawn).not.toHaveBeenCalled()
unregisterSshPtyProvider('ssh-agent-converge')
clearProviderPtyState(ownerA.ptyId)
clearProviderPtyState(ownerB.ptyId)
})
it('does not adopt a stale generation when its PTY id is reused without ownership', async () => {
const claim = {
...recoveredAgentClaim,
@@ -307,56 +125,4 @@ describe('registerPtyHandlers', () => {
clearProviderPtyState(oldOwner.ptyId)
clearProviderPtyState('pty-new-owner')
})
it('preserves an owner fence across disconnect and adopts it after reconnect', async () => {
const connectionId = 'ssh-agent-reconnect'
const claim = {
...recoveredAgentClaim,
identityDigest: '9999999999999999999999999999999999999999999'
}
const owner: AgentSessionOwnerBinding = {
claim,
generation: 'generation-reconnect',
phase: 'live',
ptyId: `ssh:${connectionId}@@pty-owner`,
surface: recoveredAgentSurface
}
const sessions = [
{
id: owner.ptyId,
incarnationId: 'incarnation-reconnect',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [owner]
}
]
const firstProvider = createAgentClaimProvider({ sessions })
setLocalPtyProvider(createAgentClaimProvider({}) as never)
registerSshPtyProvider(connectionId, firstProvider as never)
const controller = registerAgentClaimController()
const request = {
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
connectionId,
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
}
await expect(controller.spawn(request)).resolves.toMatchObject({ id: owner.ptyId })
unregisterSshPtyProvider(connectionId)
clearPtyOwnershipForConnection(connectionId)
await expect(controller.spawn({ ...request, connectionId: undefined })).rejects.toThrow(
'execution_owner_unavailable'
)
const reconnected = createAgentClaimProvider({ sessions })
registerSshPtyProvider(connectionId, reconnected as never)
await expect(controller.spawn(request)).resolves.toMatchObject({
id: owner.ptyId,
agentSessionEnsure: { disposition: 'adopted', owner }
})
expect(reconnected.spawn).not.toHaveBeenCalled()
unregisterSshPtyProvider(connectionId)
clearProviderPtyState(owner.ptyId)
})
})
@@ -1,27 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import { LocalPtyProvider } from '../providers/local-pty-provider'
import {
registerPtyHandlers,
registerSshPtyProvider,
clearPtyOwnershipForConnection,
clearProviderPtyState,
getPtyIdsForConnection,
setPtyOwnership,
setLocalPtyProvider,
unregisterSshPtyProvider
setLocalPtyProvider
} from './pty'
import {
writeRefused,
writeUnverifiable,
type WriteSettlement
} from '../../shared/pty-write-settlement'
type SettledControllerDouble = {
writeWithSettlement: (id: string, data: string) => WriteSettlement | Promise<WriteSettlement>
}
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -69,7 +56,6 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () =>
describe('registerPtyHandlers', () => {
const {
handlers,
mainWindow,
createAgentClaimProvider,
recoveredAgentClaim,
@@ -77,79 +63,18 @@ describe('registerPtyHandlers', () => {
registerAgentClaimController
} = setupPtyIpcSuite()
it('fails closed instead of routing encoded SSH PTY writes locally after disconnect', () => {
const connectionId = 'ssh-1'
const ptyId = `ssh:${connectionId}@@remote-pty`
it('fails closed instead of routing encoded SSH PTY writes locally', () => {
const ptyId = 'ssh:ssh-1@@remote-pty'
const localProvider = createAgentClaimProvider({})
const sshProvider = createAgentClaimProvider({})
setLocalPtyProvider(localProvider as never)
registerSshPtyProvider(connectionId, sshProvider as never)
setPtyOwnership(ptyId, toSshExecutionHostId(connectionId))
const controller = registerAgentClaimController()
unregisterSshPtyProvider(connectionId)
clearPtyOwnershipForConnection(connectionId)
expect(controller.write(ptyId, 'input')).toBe(false)
expect(controller.resize(ptyId, 100, 40)).toBe(false)
expect(localProvider.write).not.toHaveBeenCalled()
expect(localProvider.resize).not.toHaveBeenCalled()
registerSshPtyProvider(connectionId, sshProvider as never)
expect(controller.write(ptyId, 'reconnected')).toBe(true)
expect(controller.resize(ptyId, 120, 50)).toBe(true)
expect(sshProvider.write).toHaveBeenCalledWith(ptyId, 'reconnected')
expect(sshProvider.resize).toHaveBeenCalledWith(ptyId, 120, 50)
unregisterSshPtyProvider(connectionId)
clearProviderPtyState(ptyId)
})
it('routes settled pointer writes through the installed SSH controller and preserves uncertainty', async () => {
const connectionId = 'ssh-settled'
const ptyId = `ssh:${connectionId}@@remote-pty`
const provider = {
...createAgentClaimProvider({}),
writeWithSettlement: vi
.fn()
.mockResolvedValue(writeUnverifiable('transport_settlement_lost', true))
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, toSshExecutionHostId(connectionId))
const controller = registerAgentClaimController() as unknown as SettledControllerDouble
try {
expect(controller.writeWithSettlement).toBeTypeOf('function')
await expect(controller.writeWithSettlement(ptyId, 'pointer')).resolves.toEqual(
writeUnverifiable('transport_settlement_lost', true)
)
expect(provider.writeWithSettlement).toHaveBeenCalledWith(ptyId, 'pointer')
expect(provider.write).not.toHaveBeenCalled()
} finally {
unregisterSshPtyProvider(connectionId)
clearProviderPtyState(ptyId)
}
})
it('refuses a settled write before any bytes when the routed provider cannot settle', async () => {
const connectionId = 'ssh-unsettled'
const ptyId = `ssh:${connectionId}@@remote-pty`
const provider = createAgentClaimProvider({}) as Record<string, unknown>
// A provider predating the settled contract, reached through the production registry.
delete provider.writeWithSettlement
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, toSshExecutionHostId(connectionId))
const controller = registerAgentClaimController() as unknown as SettledControllerDouble
try {
// Synchronous by construction: the refusal happens before any effect is attempted.
expect(await controller.writeWithSettlement(ptyId, 'pointer')).toEqual(
writeRefused('provider_cannot_settle')
)
expect(provider.write).not.toHaveBeenCalled()
} finally {
unregisterSshPtyProvider(connectionId)
clearProviderPtyState(ptyId)
}
})
it('preserves a provider write refusal for callers that gate follow-up input', () => {
const provider = createAgentClaimProvider({})
provider.write.mockReturnValue(false)
@@ -188,26 +113,6 @@ describe('registerPtyHandlers', () => {
await expect(controller.probePtyLiveness('pty-unknown')).resolves.toBeNull()
})
it('answers unknown for SSH-owned ids whose provider has no probe', async () => {
const connectionId = 'ssh-probe-1'
const ptyId = `ssh:${connectionId}@@remote-pty`
setLocalPtyProvider(new LocalPtyProvider())
registerSshPtyProvider(connectionId, createAgentClaimProvider({}) as never)
setPtyOwnership(ptyId, toSshExecutionHostId(connectionId))
const controller = registerAgentClaimController()
try {
await expect(controller.probePtyLiveness(ptyId)).resolves.toBeNull()
unregisterSshPtyProvider(connectionId)
// A disconnected SSH provider is an error path, and errors never prove absence.
await expect(controller.probePtyLiveness(ptyId)).resolves.toBeNull()
} finally {
unregisterSshPtyProvider(connectionId)
clearPtyOwnershipForConnection(connectionId)
clearProviderPtyState(ptyId)
}
})
it('answers unknown for remote-scoped ids without consulting local providers', async () => {
// Why: a locally routed provider would answer confidently — and wrongly —
// for a PTY that lives on a remote Orca host.
@@ -232,9 +137,7 @@ describe('registerPtyHandlers', () => {
})
it('routes controller attach to the local daemon provider only, false on doubt', async () => {
const localProvider = createAgentClaimProvider({})
const sshProvider = createAgentClaimProvider({})
setLocalPtyProvider(localProvider as never)
registerSshPtyProvider('ssh-attach', sshProvider as never)
const controller = registerAgentClaimController()
const daemonPtyId = 'repo-1::/tmp/wt@@1a2b3c4d'
const ownedSshPtyId = 'owned-remote-pty'
@@ -244,10 +147,10 @@ describe('registerPtyHandlers', () => {
await expect(controller.attach(daemonPtyId)).resolves.toBe(true)
expect(localProvider.attach).toHaveBeenCalledWith(daemonPtyId)
// SSH-scoped sessions are excluded — leases handle their reattach.
// SSH-scoped sessions are excluded and never fall through to the local provider.
await expect(controller.attach(ownedSshPtyId)).resolves.toBe(false)
await expect(controller.attach('ssh:ssh-attach@@relay-pty')).resolves.toBe(false)
expect(sshProvider.attach).not.toHaveBeenCalled()
expect(localProvider.attach).toHaveBeenCalledTimes(1)
// Provider refusal (absent/unprovable session) answers false, not throw.
localProvider.attach.mockRejectedValueOnce(new Error('Session not found'))
@@ -260,7 +163,6 @@ describe('registerPtyHandlers', () => {
await expect(controller.attach(daemonPtyId)).resolves.toBe(false)
expect(inProcessAttach).not.toHaveBeenCalled()
} finally {
unregisterSshPtyProvider('ssh-attach')
clearPtyOwnershipForConnection('ssh-attach')
clearProviderPtyState(ownedSshPtyId)
}
@@ -349,84 +251,4 @@ describe('registerPtyHandlers', () => {
'incarnation-canonical-exited'
)
})
it('rejects renderer spawn publication when the provider reply proves exit', async () => {
const connectionId = 'ssh-renderer-exited-reply'
const appPtyId = `ssh:${connectionId}@@relay-pty`
const provider = {
spawn: vi.fn(async () => ({
id: appPtyId,
incarnationId: 'incarnation-renderer-exited',
exitedBeforeSpawnReply: true as const
})),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
}
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_renderer_exited'),
preAllocateHandleForPty: vi.fn(() => 'term_renderer_exited'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerSshPtyProvider(connectionId, provider as never)
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const leafId = '33333333-3333-4333-8333-333333333333'
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp/worktree',
connectionId,
worktreeId: 'repo::/tmp/worktree',
tabId: 'tab-renderer-exited',
leafId
})
).rejects.toThrow('agent_session_exited_during_start')
expect(runtime.registerPty).not.toHaveBeenCalled()
expect(runtime.registerPreAllocatedHandleForPty).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(getPtyIdsForConnection(connectionId)).toEqual([])
} finally {
unregisterSshPtyProvider(connectionId)
}
})
})
@@ -1,5 +1,5 @@
import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
@@ -53,12 +53,7 @@ vi.mock('../pi/titlebar-extension-service', () => ({
piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() }
}))
import {
registerPtyHandlers,
registerSshPtyProvider,
setLocalPtyProvider,
unregisterSshPtyProvider
} from './pty'
import { registerPtyHandlers, setLocalPtyProvider } from './pty'
import type { IPtyProvider, PtyProcessInfo } from '../providers/types'
// The runtime's worktree.ps liveness refresh calls the aggregate inventory (no connectionId)
@@ -89,10 +84,6 @@ function createProvider(
return { provider, calls }
}
function session(id: string): PtyProcessInfo {
return { id, cwd: '/tmp', title: id } as unknown as PtyProcessInfo
}
const mainWindow = {
isDestroyed: () => false,
webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() }
@@ -122,63 +113,6 @@ function captureController(): {
}
describe('aggregate PTY process inventory', () => {
const registered: string[] = []
function register(connectionId: string, provider: IPtyProvider): void {
registerSshPtyProvider(connectionId, provider)
registered.push(connectionId)
}
afterEach(() => {
for (const connectionId of registered.splice(0)) {
unregisterSshPtyProvider(connectionId)
}
})
it('still reports local and healthy relays when one SSH relay rejects', async () => {
const local = createProvider([session('local-pty')])
const healthy = createProvider([session('ssh:conn-ok@@pty')])
const broken = createProvider([], 'reject')
setLocalPtyProvider(local.provider)
register('conn-ok', healthy.provider)
register('conn-broken', broken.provider)
const controller = captureController()
const sessions = await controller.listProcesses()
// Pre-fix this rejected: Promise.all surfaced the broken relay's error, the runtime
// read it as "no inventory", and no PTY anywhere was retired.
expect(sessions.map((entry) => entry.id).sort()).toEqual(['local-pty', 'ssh:conn-ok@@pty'])
})
it('bounds every relay list by the caller deadline instead of the mux default', async () => {
const local = createProvider([session('local-pty')])
const remote = createProvider([session('ssh:conn-a@@pty')])
setLocalPtyProvider(local.provider)
register('conn-a', remote.provider)
const controller = captureController()
const deadlineMs = Date.now() + 2500
await controller.listProcesses(undefined, { deadlineMs })
// Without a forwarded deadline an unanswered relay list runs to the SSH mux's own
// 30s default, far past the runtime's 3s budget for the whole refresh.
expect(remote.calls).toEqual([{ opts: { deadlineMs } }])
})
it('forwards the caller deadline on a targeted single-connection list', async () => {
const local = createProvider([session('local-pty')])
const remote = createProvider([session('ssh:conn-a@@pty')])
setLocalPtyProvider(local.provider)
register('conn-a', remote.provider)
const controller = captureController()
const deadlineMs = Date.now() + 1200
await controller.listProcesses('ssh:conn-a', { deadlineMs })
expect(remote.calls).toEqual([{ opts: { deadlineMs } }])
})
it('fails the aggregate when the local provider cannot list', async () => {
const local = createProvider([], 'reject')
setLocalPtyProvider(local.provider)
@@ -1,13 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { makePaneKey } from '../../shared/stable-pane-id'
import {
registerPtyHandlers,
registerSshPtyProvider,
deletePtyOwnership,
setPtyOwnership,
setLocalPtyProvider
} from './pty'
import { registerPtyHandlers, setPtyOwnership, setLocalPtyProvider } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -261,78 +254,6 @@ describe('registerPtyHandlers', () => {
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-incarnated', 0, 'incarnation-live')
})
it('runtime controller kill routes app-scoped SSH ids through the parsed provider when ownership is absent', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: localShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const shutdown = vi.fn(async () => undefined)
const store = { markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() }
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true)
await new Promise((resolve) => setImmediate(resolve))
expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: false })
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
})
it('runtime controller kill tombstones app-scoped SSH ids when ownership and provider are absent', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
@@ -415,202 +336,6 @@ describe('registerPtyHandlers', () => {
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('keeps a rejected SSH PTY unverifiable after kill shutdown fails transiently', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = {
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn(),
markPtyLivenessUnverifiable: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('Multiplexer disposed')),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh:ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
retireRejectedPty: (ptyId: string, stopConfirmed: boolean) => void
}
try {
expect(controller.kill('remote-pty')).toBe(true)
await new Promise((resolve) => setImmediate(resolve))
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
controller.retireRejectedPty('remote-pty', false)
} finally {
warnSpy.mockRestore()
deletePtyOwnership('remote-pty')
}
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
'remote-pty',
'a follow-up stop was issued but its outcome could not be verified'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
expect(runtime.onPtyExit).not.toHaveBeenCalledWith('remote-pty', 0, undefined)
})
it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => {
const sshSpawn = vi.fn(async (_opts: { env: Record<string, string> }) => ({
id: 'ssh-pty'
}))
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '0'
try {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {
FOO: 'bar',
ORCA_PANE_KEY: 'tab-1:0',
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1'
},
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
connectionId: 'ssh-1'
})
const env = sshSpawn.mock.calls.at(-1)![0].env
expect(env.FOO).toBe('bar')
expect(env.ORCA_PANE_KEY).toBeUndefined()
expect(env.ORCA_TAB_ID).toBeUndefined()
expect(env.ORCA_WORKTREE_ID).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
// Why: the local hook server's userData-relative endpoint path is meaningless on the remote box; assert no leak.
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
} finally {
if (prevFlag === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
} else {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = prevFlag
}
}
})
it('forwards ORCA_PANE_KEY/TAB_ID/WORKTREE_ID over SSH by default', async () => {
const sshSpawn = vi.fn(async (_opts: { env: Record<string, string> }) => ({
id: 'ssh-pty'
}))
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
try {
const leafId = '22222222-2222-4222-8222-222222222222'
const paneKey = makePaneKey('tab-2', leafId)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {
FOO: 'bar',
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-2',
ORCA_WORKTREE_ID: 'wt-2'
},
connectionId: 'ssh-1',
tabId: 'tab-2',
leafId
})
const env = sshSpawn.mock.calls.at(-1)![0].env
expect(env.ORCA_PANE_KEY).toBe(paneKey)
expect(env.ORCA_TAB_ID).toBe('tab-2')
expect(env.ORCA_WORKTREE_ID).toBe('wt-2')
// Local hook server coords must NOT cross the wire — the relay is the source of truth.
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
} finally {
if (prevFlag === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
} else {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = prevFlag
}
}
})
})
})
})
@@ -1,21 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import {
openCodeBuildPtyEnvMock,
openCodeClearPtyMock,
piBuildPtyEnvMock,
piClearPtyMock
} from './pty-ipc-mock-registry'
import { openCodeClearPtyMock, piBuildPtyEnvMock, piClearPtyMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures'
import { makePaneKey } from '../../shared/stable-pane-id'
import { SshPtyAbsentFromRelayError } from '../providers/ssh-pty-errors'
import {
registerPtyHandlers,
registerSshPtyProvider,
deletePtyOwnership,
setPtyOwnership,
setLocalPtyProvider
} from './pty'
import { registerPtyHandlers, setLocalPtyProvider } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -332,308 +319,6 @@ describe('registerPtyHandlers', () => {
{ resetIncarnation: true }
)
})
it('does NOT inject host-local env on SSH spawns (connectionId set)', async () => {
const sshSpawn = vi.fn(
async (_opts: {
env: Record<string, string>
envToDelete?: string[]
paneKey?: string
tabId?: string
}) => ({
id: 'ssh-pty'
})
)
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
(() => ({
httpProxyUrl: 'http://proxy.example:8080',
httpProxyBypassRules: 'localhost',
codexSystemDefaultRealHomeEnabled: true
})) as never,
undefined,
store as never
)
const leafId = '11111111-1111-4111-8111-111111111111'
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: { FOO: 'bar', ORCA_PANE_KEY: makePaneKey('tab-1', leafId) },
connectionId: 'ssh-1',
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId
})
const spawnOptions = sshSpawn.mock.calls.at(-1)![0]
const env = spawnOptions.env
// Why: host-local vars must be absent over SSH (they point at the local host/disk) — shipping them is useless or a credential leak.
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
expect(env.MIMOCODE_HOME).toBeUndefined()
expect(env.ORCA_MIMOCODE_HOME).toBeUndefined()
expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined()
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.CODEX_HOME).toBeUndefined()
expect(env.HTTP_PROXY).toBeUndefined()
expect(env.HTTPS_PROXY).toBeUndefined()
expect(env.NO_PROXY).toBeUndefined()
expect(env.FOO).toBe('bar')
// Why: real-home routing is host-only. A null local-home resolver on
// SSH must not become a request to alter the remote Codex environment.
expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME')
expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME')
expect(spawnOptions.paneKey).toBe(makePaneKey('tab-1', leafId))
expect(spawnOptions.tabId).toBe('tab-1')
expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled()
expect(piBuildPtyEnvMock).not.toHaveBeenCalled()
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-1',
ptyId: 'ssh-pty',
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
state: 'attached'
})
)
expect(store.persistPtyBinding).toHaveBeenCalledWith(
{
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
ptyId: 'ssh-pty',
origin: 'spawn'
},
'ssh:ssh-1'
)
store.upsertSshRemotePtyLease.mockClear()
store.persistPtyBinding.mockClear()
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: { ORCA_PANE_KEY: 'tab-1:pane:1' },
connectionId: 'ssh-1',
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledTimes(1)
const legacySpawnOptions = sshSpawn.mock.calls.at(-1)?.[0]
expect(legacySpawnOptions?.env.ORCA_PANE_KEY).toBeUndefined()
expect(legacySpawnOptions?.paneKey).toBeUndefined()
expect(legacySpawnOptions?.tabId).toBe('tab-1')
expect(store.upsertSshRemotePtyLease.mock.calls[0]?.[0]).not.toHaveProperty('leafId')
expect(store.persistPtyBinding).not.toHaveBeenCalled()
})
it('marks a caller-supplied SSH session expired when remote reattach is gone', async () => {
const sshSpawn = vi.fn(async () => {
// The class, not the message: `SSH_SESSION_EXPIRED` is also what a live PTY whose
// source stream needs restoring refuses with, and only this one is host-reported absence.
throw new SshPtyAbsentFromRelayError('SSH_SESSION_EXPIRED: remote-pty')
})
const store = {
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
connectionId: 'ssh-1',
sessionId: 'remote-pty'
})
).rejects.toThrow('SSH_SESSION_EXPIRED: remote-pty')
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'expired')
})
it('leaves the lease alone when the refusal did not observe the process', async () => {
// A `restoreRequired` reattach is refused with the SAME `SSH_SESSION_EXPIRED` text, and it
// means the opposite: the PTY is live, only its source stream could not be resumed. The
// lease and the in-memory ownership are between them this client's only record that the
// remote process exists, and #9819's sweep reads a PTY it has no record of as one it may
// SIGKILL on the next connect. Erasing them here is how a live shell gets reaped.
const sshSpawn = vi.fn(async () => {
throw new Error('SSH_SESSION_EXPIRED: remote-pty')
})
const store = {
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
connectionId: 'ssh-1',
sessionId: 'remote-pty'
})
).rejects.toThrow('SSH_SESSION_EXPIRED: remote-pty')
// The spawn still fails; what must not happen is the destructive bookkeeping.
expect(store.markSshRemotePtyLease).not.toHaveBeenCalled()
})
it('marks a scoped SSH session expired using the raw relay lease id', async () => {
const scopedPtyId = 'ssh:ssh-1@@remote-pty'
const sshSpawn = vi.fn(async () => {
// The class, not the message: `SSH_SESSION_EXPIRED` is also what a live PTY whose
// source stream needs restoring refuses with, and only this one is host-reported absence.
throw new SshPtyAbsentFromRelayError('SSH_SESSION_EXPIRED: remote-pty')
})
const store = {
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership(scopedPtyId, 'ssh:ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
try {
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
connectionId: 'ssh-1',
sessionId: scopedPtyId
})
).rejects.toThrow('SSH_SESSION_EXPIRED: remote-pty')
} finally {
deletePtyOwnership(scopedPtyId)
}
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'expired')
expect(openCodeClearPtyMock).toHaveBeenCalledWith(scopedPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(scopedPtyId)
})
})
})
})
@@ -1,18 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
import {
SSH_PTY_IDENTITY_MISMATCH_ERROR,
SSH_SESSION_EXPIRED_ERROR
} from '../providers/ssh-pty-errors'
import {
registerPtyHandlers,
registerSshPtyProvider,
deletePtyOwnership,
setPtyOwnership,
setLocalPtyProvider
} from './pty'
import { registerPtyHandlers, setLocalPtyProvider } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -59,193 +48,10 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () =>
)
describe('registerPtyHandlers', () => {
const { handlers, mainWindow, mainWindowIpcEvent, getPtyWriteListener } = setupPtyIpcSuite()
const { handlers, mainWindow } = setupPtyIpcSuite()
describe('spawn environment', () => {
describe('daemon-active provider (parity with LocalPtyProvider)', () => {
// Why: under the daemon, LocalPtyProvider.buildSpawnEnv never runs, so host-local env injection must happen in the pty:spawn handler instead.
it('does not clear a scoped SSH session when remote reattach rejects an identity mismatch', async () => {
const scopedPtyId = 'ssh:ssh-1@@remote-pty'
const remoteWrite = vi.fn()
const sshSpawn = vi.fn(async () => {
throw new Error(
`${SSH_SESSION_EXPIRED_ERROR}: remote-pty ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
)
})
const store = {
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: remoteWrite,
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership(scopedPtyId, 'ssh:ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
try {
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
connectionId: 'ssh-1',
sessionId: scopedPtyId
})
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'expired'
)
expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(scopedPtyId)
expect(piClearPtyMock).not.toHaveBeenCalledWith(scopedPtyId)
getPtyWriteListener()(mainWindowIpcEvent, {
id: scopedPtyId,
data: 'echo still-owned'
})
expect(remoteWrite).toHaveBeenCalledWith(scopedPtyId, 'echo still-owned')
} finally {
deletePtyOwnership(scopedPtyId)
}
})
it('does not tombstone an SSH lease when explicit kill shutdown fails transiently', async () => {
const store = {
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('Multiplexer disposed')),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh:ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
try {
await expect(
handlers.get('pty:kill')!(null, { id: 'remote-pty', keepHistory: false })
).rejects.toThrow('Multiplexer disposed')
} finally {
deletePtyOwnership('remote-pty')
}
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
})
it('marks an SSH lease terminated after runtime controller kill succeeds', async () => {
const shutdown = vi.fn(async () => undefined)
const store = {
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh:ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('remote-pty')).toBe(true)
// Why: kill's shutdown runs through the exit-detection wrapper (extra async hops), so one microtask flush isn't enough.
await new Promise((resolve) => setImmediate(resolve))
expect(shutdown).toHaveBeenCalledWith('remote-pty', { immediate: false })
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('controller kill does not duplicate exits when the provider emits exit during shutdown', async () => {
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
const shutdown = vi.fn(async (id: string) => {
@@ -408,68 +214,6 @@ describe('registerPtyHandlers', () => {
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
).toEqual([['pty:exit', { id: 'local-pty', code: 0, preserveRendererBinding: true }]])
})
it('passes keepHistory through runtime controller stopAndWait', async () => {
vi.useFakeTimers()
const shutdown = vi.fn(async () => undefined)
const store = {
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh:ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
}
const stopPromise = controller.stopAndWait('remote-pty', { keepHistory: true })
await vi.advanceTimersByTimeAsync(1_200)
await expect(stopPromise).resolves.toBe(true)
expect(shutdown).toHaveBeenCalledWith('remote-pty', {
immediate: true,
keepHistory: true
})
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined)
})
it('splits the teardown budget so the liveness RPC gets only what shutdown left', async () => {
// Why: sequential RPCs must share one absolute deadline; otherwise both get
// the full ~9.5s bound and their sum overruns the 10s sweep deadline (Finding 1).
@@ -1,17 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { makeDeferred } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import {
acceptSshPtyOutputData,
acceptSshPtyOutputExit,
closeSshPtyOutputGeneration
} from './ssh-pty-output-intake-registry'
import {
registerPtyHandlers,
registerSshPtyProvider,
getPtyRendererDeliveryDebugSnapshot,
unregisterSshPtyProvider
} from './pty'
import { acceptSshPtyOutputData, acceptSshPtyOutputExit } from './ssh-pty-output-intake-registry'
import { registerPtyHandlers, getPtyRendererDeliveryDebugSnapshot } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -65,76 +55,6 @@ describe('registerPtyHandlers', () => {
getPtyDataSendCalls
} = setupPtyIpcSuite()
it('pauses and resumes the exact SSH provider generation across reconnect replacement', async () => {
vi.useFakeTimers()
const completion = makeDeferred()
let sequence = 0
let captures = 0
const runtime = {
setPtyController: vi.fn(),
setRemoteTerminalSourceRangeConsumerHooks: vi.fn(),
getPtyOutputSequence: vi.fn(() => sequence),
acceptPtyDataBounded: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => {
sequence += rawLength
captures++
return {
sequence,
completion: captures === 1 ? completion.promise : Promise.resolve()
}
})
}
const original = {
providerGeneration: 41,
hasPtyDeliveryPauseAdapter: () => true,
pauseProducer: vi.fn(),
resumeProducer: vi.fn()
}
const replacement = {
providerGeneration: 42,
hasPtyDeliveryPauseAdapter: () => true,
pauseProducer: vi.fn(),
resumeProducer: vi.fn()
}
const id = 'ssh:ssh-generation-replacement@@relay-pty'
const receipts: Promise<unknown>[] = []
try {
registerPtyHandlers(mainWindow as never, runtime as never)
registerSshPtyProvider('ssh-generation-replacement', original as never)
const running = acceptSshPtyOutputData({
id,
data: 'a'.repeat(256 * 1024),
providerGeneration: 41,
ptyIncarnation: 'incarnation-41',
rawLength: 256 * 1024,
transformed: false
})
receipts.push(running)
registerSshPtyProvider('ssh-generation-replacement', replacement as never)
const pressured = acceptSshPtyOutputData({
id,
data: 'b',
providerGeneration: 41,
ptyIncarnation: 'incarnation-41',
rawLength: 1,
transformed: false
})
receipts.push(pressured)
expect(original.pauseProducer).toHaveBeenCalledWith(id)
expect(replacement.pauseProducer).not.toHaveBeenCalled()
completion.resolve()
await Promise.all([running, pressured])
expect(original.resumeProducer).toHaveBeenCalledWith(id)
expect(replacement.resumeProducer).not.toHaveBeenCalled()
} finally {
completion.resolve()
await Promise.allSettled(receipts)
closeSshPtyOutputGeneration(41, 'test-cleanup')
unregisterSshPtyProvider('ssh-generation-replacement')
}
})
it('rejects local data while an SSH renderer exit waits for projection settlement', async () => {
const provider = installObservableDaemonTestProvider()
let sequence = 0
+1 -16
View File
@@ -51,7 +51,7 @@ import {
LocalPtyProvider,
_resetLocalPtyProviderStateForTest
} from '../providers/local-pty-provider'
import { setLocalPtyProvider, unregisterSshPtyProvider } from './pty'
import { setLocalPtyProvider } from './pty'
import { _resetHiddenRendererPtyDeliveryGateForTest } from './pty-hidden-delivery-gate'
import { __resetShellStartupEnvCache } from '../pty/shell-startup-env'
import { _resetWslCachesForTests } from '../wsl'
@@ -291,21 +291,6 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment {
_resetLocalPtyProviderStateForTest()
_resetWslCachesForTests()
vi.useRealTimers()
// Why: sshProviders is module-level state; a leftover id leaks into later tests (pty:listSessions sweeps every provider).
for (const leakedConnectionId of [
'ssh-1',
'ssh-a',
'ssh-b',
'ssh-expired-runtime',
'ssh-fresh-fail',
'ssh-reattach-1',
'ssh-reattach-fail',
'ssh-reattach-ok',
'ssh-runtime-env',
'ssh-generation-replacement'
]) {
unregisterSshPtyProvider(leakedConnectionId)
}
setLocalPtyProvider(new LocalPtyProvider())
envScope.restoreProcessEnv()
})
@@ -1,18 +1,8 @@
import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture'
import { describe, expect, it, vi } from 'vitest'
import { spawnMock, registerPtyMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { makePaneKey } from '../../shared/stable-pane-id'
import {
SSH_SESSION_EXPIRED_ERROR,
SshPtyProvenExitedOnRelayError
} from '../providers/ssh-pty-errors'
import {
registerPtyHandlers,
registerSshPtyProvider,
setLocalPtyProvider,
unregisterSshPtyProvider
} from './pty'
import { registerPtyHandlers, setLocalPtyProvider } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -61,143 +51,6 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () =>
describe('registerPtyHandlers', () => {
const { handlers, mainWindow } = setupPtyIpcSuite()
it('retires a dead owner from the exact SSH host session before fresh recovery', async () => {
const connectionId = 'ssh-dead-stable-pane'
const hostId = `ssh:${connectionId}`
const tabId = 'tab-dead-ssh-owner'
const leafId = '34343434-3434-4434-8434-343434343434'
const paneKey = makePaneKey(tabId, leafId)
const worktreeId = 'repo-ssh::/remote/dead-stable-pane'
const deadPtyId = `ssh:${connectionId}@@dead-relay-pty`
const freshPtyId = `ssh:${connectionId}@@fresh-relay-pty`
const remoteSpawn = vi.fn(async (options: { attachOnly?: boolean; command?: string }) => {
if (options.attachOnly) {
// The relay's raw wire text never reaches a pane untyped; the SSH reattach path mints the
// proven-exit class for the one refusal the relay backed with a pid probe.
throw new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: dead-relay-pty`)
}
return { id: freshPtyId, incarnationId: 'inc-fresh-ssh-owner' }
})
registerSshPtyProvider(connectionId, {
spawn: remoteSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
let session = {
tabsByWorktree: {
[worktreeId]: [{ id: tabId, worktreeId, ptyId: deadPtyId }]
},
terminalLayoutsByTabId: {
[tabId]: {
root: { type: 'leaf' as const, leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: deadPtyId }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-ssh-owner' }
}
const store = withDurableRuntimeStore({
getWorkspaceSession: vi.fn((requestedHostId?: string) => {
expect(requestedHostId).toBe(hostId)
return session
}),
setWorkspaceSession: vi.fn((next, requestedHostId?: string) => {
expect(requestedHostId).toBe(hostId)
session = next
}),
flushOrThrow: vi.fn(),
persistPtyBinding: vi.fn(),
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
})
const runtime = {
setPtyController: vi.fn(),
resolveTerminalPane: vi.fn(() => {
throw new Error('terminal_not_found')
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term-fresh-ssh-owner'),
registerPreAllocatedHandleForPty: vi.fn(),
beginPtyRegistration: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
assertPtyRegistrationAllowed: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
seedHeadlessTerminal: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const mounted = await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/remote/dead-stable-pane',
command: 'codex resume exact-dead-ssh-provider-session',
connectionId,
worktreeId,
tabId,
leafId,
env: {
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
}
})
expect(mounted).toMatchObject({ id: freshPtyId, incarnationId: 'inc-fresh-ssh-owner' })
expect(remoteSpawn).toHaveBeenCalledTimes(2)
expect(remoteSpawn.mock.calls[0]?.[0]).toMatchObject({
attachOnly: true,
sessionId: deadPtyId,
command: undefined
})
expect(remoteSpawn.mock.calls[1]?.[0]).toMatchObject({
command: 'codex resume exact-dead-ssh-provider-session'
})
expect(store.setWorkspaceSession).toHaveBeenCalledWith(expect.anything(), hostId)
expect(store.persistPtyBinding).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId,
tabId,
leafId,
ptyId: freshPtyId
}),
hostId
)
} finally {
unregisterSshPtyProvider(connectionId)
}
})
it('fails closed when runtime and persisted stable-pane owners conflict', async () => {
const tabId = 'tab-conflicting-owner'
const leafId = '99999999-9999-4999-8999-999999999999'
@@ -447,109 +300,4 @@ describe('registerPtyHandlers', () => {
expect(result.id).toEqual(expect.any(String))
expect(providerSpawn).toHaveBeenCalledTimes(2)
})
it('records SSH leases for runtime-owned headless session bindings', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const remoteSpawn = vi.fn(async () => ({ id: 'ssh:ssh-1@@relay-pty' }))
registerSshPtyProvider('ssh-1', {
spawn: remoteSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-1',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: 'ssh:ssh-1@@relay-pty',
persistHostSessionBinding: true
})
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-1',
ptyId: 'relay-pty',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
state: 'attached'
})
)
expect(store.persistPtyBinding).toHaveBeenCalledWith(
{
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-1@@relay-pty',
mayCreate: false,
origin: 'spawn'
},
'ssh:ssh-1'
)
expect(store.persistPtyBinding.mock.invocationCallOrder[0]!).toBeLessThan(
store.upsertSshRemotePtyLease.mock.invocationCallOrder[0]!
)
unregisterSshPtyProvider('ssh-1')
})
})
+1 -148
View File
@@ -1,8 +1,6 @@
import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harness'
import { registerSshPtyProvider, unregisterSshPtyProvider } from './pty/provider/registry'
import { toAppSshPtyId } from '../providers/ssh-pty-id'
import { TerminalKilledError } from '../daemon/daemon-pty-lifecycle-errors'
import { makePaneKey } from '../../shared/stable-pane-id'
import { registerPtyHandlers, setLocalPtyProvider } from './pty'
@@ -492,149 +490,4 @@ describe('pty:spawn replacing a pane owner', () => {
)
expect(leafBinding()).toBe('successor')
})
/** The SSH pane lives only in its host partition; the local leaf keeps an unrelated id. */
async function withSshRestart(
run: (
host: RestartHarness & { oldId: string; newId: string; hostId: string },
restart: (extra?: { replacesPtyId?: string }) => Promise<unknown>
) => Promise<void>
): Promise<void> {
const harness = installRestartHarness()
const connectionId = 'restart-ssh'
const hostId = 'ssh:restart-ssh'
const oldId = toAppSshPtyId(connectionId, 'pty-old')
const newId = toAppSshPtyId(connectionId, 'pty-new')
harness.partitions[hostId] = seedSession(oldId)
Object.assign(harness.store, {
markSshRemotePtyLease: vi.fn(),
upsertSshRemotePtyLease: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn()
})
let oldAlive = true
harness.shutdown.mockImplementation(async () => {
oldAlive = false
})
harness.providerSpawn.mockImplementation(async (options) => {
if (!options.attachOnly) {
return { id: newId, incarnationId: 'inc-new' }
}
if (!oldAlive) {
throw new TerminalKilledError(oldId)
}
return { id: oldId, incarnationId: 'inc-old', isReattach: true }
})
registerSshPtyProvider(
connectionId,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same fake provider surface used by the local restart fixture.
harness.provider as unknown as Parameters<typeof registerSshPtyProvider>[1]
)
const localSpawn = vi.fn(() => {
throw new Error('wrong execution host')
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test must never reach the local provider.
setLocalPtyProvider({
...harness.provider,
spawn: localSpawn,
shutdown: localSpawn
} as unknown as Parameters<typeof setLocalPtyProvider>[0])
registerWithFakes(mainWindow, harness.runtime, harness.store)
try {
await run({ ...harness, oldId, newId, hostId }, async (extra = {}) =>
handlers.get('pty:spawn')!(null, { ...restartSpawnArgs(extra), connectionId })
)
expect(localSpawn).not.toHaveBeenCalled()
expect(harness.leafBinding()).toBe('pty-old')
} finally {
unregisterSshPtyProvider(connectionId)
}
}
it('swaps the direct-SSH host binding without touching the local provider', async () => {
await withSshRestart(async (host, restart) => {
await expect(restart({ replacesPtyId: host.oldId })).resolves.toMatchObject({
id: host.newId
})
expect(host.shutdown).toHaveBeenCalledWith(
host.oldId,
expect.objectContaining({ immediate: true })
)
expect(host.store.persistPtyBinding).toHaveBeenCalledWith(expect.any(Function), host.hostId)
expect(host.leafBinding(host.hostId)).toBe(host.newId)
expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
})
})
it('reaps a direct-SSH replacement when a successor binds the host pane during its spawn', async () => {
await withSshRestart(async (host, restart) => {
host.providerSpawn.mockImplementationOnce(async () => {
host.partitions[host.hostId].terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] =
'successor'
return { id: host.newId, incarnationId: 'inc-new' }
})
await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow(
'terminal_pane_owner_changed'
)
expect(host.shutdown).toHaveBeenCalledWith(
host.newId,
expect.objectContaining({ immediate: true })
)
expect(host.leafBinding(host.hostId)).toBe('successor')
})
})
it('clears the direct-SSH stopped binding when the replacement fails, so the remount starts fresh', async () => {
await withSshRestart(async (host, restart) => {
host.providerSpawn.mockRejectedValueOnce(new Error('spawn failed'))
await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow('spawn failed')
const boundAfterFailure = host.leafBinding(host.hostId)
await expect(restart()).resolves.toMatchObject({ id: host.newId })
expect(boundAfterFailure).toBeUndefined()
expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
})
})
it.each(['darwin', 'linux', 'win32'])(
'restarts a folder workspace on %s without deleting its pane',
async (platform) => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
onTestFinished(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
const { store, runtime, leafBinding } = installRestartHarness()
const folderId = 'folder:restart-folder'
const session = store.getWorkspaceSession()
session.tabsByWorktree[folderId] = session.tabsByWorktree[worktreeId].map((tab) => ({
...tab,
worktreeId: folderId
}))
session.tabsByWorktree[worktreeId] = []
const folder = {
id: 'restart-folder',
folderPath: process.cwd(),
name: 'Restart',
projectGroupId: null
}
Object.assign(store, {
getFolderWorkspaces: vi.fn(() => [folder]),
getFolderWorkspace: vi.fn(() => folder)
})
registerWithFakes(mainWindow, runtime, store)
await expect(
handlers.get('pty:spawn')!(null, {
...restartSpawnArgs({ replacesPtyId: 'pty-old' }),
worktreeId: folderId,
cwd: process.cwd()
})
).resolves.toMatchObject({ id: 'pty-new' })
expect(store.getWorkspaceSession().tabsByWorktree[folderId]).toHaveLength(1)
expect(leafBinding()).toBe('pty-new')
expect(store.getWorkspaceSession().terminalLayoutsByTabId[tabId].root).toEqual({
type: 'leaf',
leafId
})
}
)
})
+37 -84
View File
@@ -2,11 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
import { spawnMock } from './pty-ipc-mock-registry'
import { makeDeferred } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { RuntimePtyController } from '../runtime/runtime-pty-controller-contract'
import {
registerPtyHandlers,
registerSshPtyProvider,
deletePtyOwnership,
setPtyOwnership,
setLocalPtyProvider,
@@ -62,22 +61,6 @@ describe('registerPtyHandlers', () => {
const { handlers, mainWindow, installDaemonTestProvider, installObservableDaemonTestProvider } =
setupPtyIpcSuite()
it('routes runtime foreground confirmation to the provider owning the captured PTY', async () => {
const confirmForegroundProcess = vi.fn(async () => 'codex')
registerSshPtyProvider('ssh-1', { confirmForegroundProcess } as never)
setPtyOwnership('remote-pty', 'ssh:ssh-1')
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
confirmForegroundProcess: (ptyId: string) => Promise<string | null>
}
await expect(controller.confirmForegroundProcess('remote-pty')).resolves.toBe('codex')
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
expect(confirmForegroundProcess).toHaveBeenCalledWith('remote-pty')
deletePtyOwnership('remote-pty')
})
it('routes runtime exact liveness without enumerating provider sessions', () => {
const provider = getLocalPtyProvider()
const hasPty = vi.spyOn(provider, 'hasPty').mockImplementation((id) => id === 'live-pty')
@@ -94,72 +77,6 @@ describe('registerPtyHandlers', () => {
expect(hasPty).toHaveBeenCalledTimes(2)
expect(listProcesses).not.toHaveBeenCalled()
})
it('scopes runtime inventories to the requested provider', async () => {
const localList = vi
.spyOn(getLocalPtyProvider(), 'listProcesses')
.mockResolvedValue([{ id: 'local-pty', title: 'Local', cwd: '/local' }])
const sshAList = vi.fn(async () => [{ id: 'ssh-a-pty' }])
const sshBList = vi.fn(async () => {
throw new Error('ssh-b unavailable')
})
registerSshPtyProvider('ssh-a', { listProcesses: sshAList } as never)
registerSshPtyProvider('ssh-b', { listProcesses: sshBList } as never)
setPtyOwnership('ssh-b-pty', 'ssh:ssh-b')
const runtime = {
setPtyController: vi.fn((_controller: RuntimePtyController) => {}),
markPtyLivenessUnverifiable: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0]
await expect(controller?.listProcesses?.('local')).resolves.toEqual([
{ id: 'local-pty', title: 'Local', cwd: '/local' }
])
expect(localList).toHaveBeenCalledOnce()
expect(sshAList).not.toHaveBeenCalled()
expect(sshBList).not.toHaveBeenCalled()
await expect(controller?.listProcesses?.('ssh:ssh-a')).resolves.toEqual([{ id: 'ssh-a-pty' }])
expect(sshAList).toHaveBeenCalledOnce()
expect(sshBList).not.toHaveBeenCalled()
// STA-517: the aggregate used to propagate ssh-b's failure, which cost the runtime the
// whole liveness inventory — so no PTY was ever proven dead and mobile kept every
// retained pane "active". One unreachable relay now drops out of the answer instead.
await expect(controller?.listProcesses?.()).resolves.toEqual([
{ id: 'local-pty', title: 'Local', cwd: '/local' },
{ id: 'ssh-a-pty' }
])
expect(sshBList).toHaveBeenCalledOnce()
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
'ssh-b-pty',
'ssh-b unavailable'
)
await expect(controller?.listProcessesWithHostScope?.()).resolves.toEqual({
processes: [{ id: 'local-pty', title: 'Local', cwd: '/local' }, { id: 'ssh-a-pty' }],
hostIds: [LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId('ssh-a')]
})
expect(sshBList).toHaveBeenCalledTimes(2)
deletePtyOwnership('ssh-b-pty')
})
it('returns unavailable runtime confirmation for unsupported or missing providers', async () => {
registerSshPtyProvider('ssh-1', {} as never)
setPtyOwnership('unsupported-pty', 'ssh:ssh-1')
setPtyOwnership('missing-pty', 'ssh:missing-connection')
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
confirmForegroundProcess: (ptyId: string) => Promise<string | null>
}
await expect(controller.confirmForegroundProcess('unsupported-pty')).resolves.toBeNull()
await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull()
deletePtyOwnership('unsupported-pty')
deletePtyOwnership('missing-pty')
})
it('rethrows non-not-found local provider shutdown failures', async () => {
setLocalPtyProvider({
spawn: vi.fn(),
@@ -190,6 +107,42 @@ describe('registerPtyHandlers', () => {
'daemon unavailable'
)
})
it('scopes runtime inventories to this machine', async () => {
const localList = vi
.spyOn(getLocalPtyProvider(), 'listProcesses')
.mockResolvedValue([{ id: 'local-pty', title: 'Local', cwd: '/local' }])
const runtime = {
setPtyController: vi.fn((_controller: RuntimePtyController) => {}),
markPtyLivenessUnverifiable: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0]
await expect(controller?.listProcesses?.('local')).resolves.toEqual([
{ id: 'local-pty', title: 'Local', cwd: '/local' }
])
await expect(controller?.listProcesses?.('ssh:ssh-a')).rejects.toThrow(
'No PTY provider for connection "ssh-a"'
)
await expect(controller?.listProcessesWithHostScope?.()).resolves.toEqual({
processes: [{ id: 'local-pty', title: 'Local', cwd: '/local' }],
hostIds: [LOCAL_EXECUTION_HOST_ID]
})
expect(localList).toHaveBeenCalledTimes(2)
})
it('returns unavailable runtime confirmation for an SSH-owned PTY', async () => {
setPtyOwnership('missing-pty', 'ssh:missing-connection')
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
confirmForegroundProcess: (ptyId: string) => Promise<string | null>
}
await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull()
deletePtyOwnership('missing-pty')
})
it('rejects runtime terminal IDs before unowned local provider routing', async () => {
const shutdown = vi.spyOn(getLocalPtyProvider(), 'shutdown')
handlers.clear()
@@ -1,15 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { spawnMock, openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock-registry'
import { spawnMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { makePaneKey } from '../../shared/stable-pane-id'
import { SSH_SESSION_EXPIRED_ERROR, SshPtyAbsentFromRelayError } from '../providers/ssh-pty-errors'
import {
registerPtyHandlers,
registerSshPtyProvider,
deletePtyOwnership,
setPtyOwnership,
unregisterSshPtyProvider
} from './pty'
import { registerPtyHandlers } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -56,7 +48,7 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () =>
)
describe('registerPtyHandlers', () => {
const { mainWindow, mainWindowIpcEvent, getPtyWriteListener } = setupPtyIpcSuite()
const { mainWindow } = setupPtyIpcSuite()
it('rejects runtime-owned binding persistence without complete stable identity', async () => {
type RuntimeSpawnController = {
@@ -118,530 +110,4 @@ describe('registerPtyHandlers', () => {
expect(spawnMock).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
})
it('refreshes SSH leases after successful runtime-owned reattach binding', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string; isReattach?: boolean }>
}
registerSshPtyProvider('ssh-reattach-ok', {
spawn: vi.fn(async () => ({ id: 'ssh:ssh-reattach-ok@@relay-pty', isReattach: true })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-ok',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: 'ssh:ssh-reattach-ok@@relay-pty',
persistHostSessionBinding: true
})
expect(store.persistPtyBinding).toHaveBeenCalledWith(
{
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-reattach-ok@@relay-pty',
mayCreate: false,
origin: 'reattach'
},
'ssh:ssh-reattach-ok'
)
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-reattach-ok',
ptyId: 'relay-pty',
state: 'attached',
lastAttachedAt: expect.any(Number)
})
)
} finally {
unregisterSshPtyProvider('ssh-reattach-ok')
}
})
it('strips runtime-owned SSH pane env when remote agent hooks are disabled', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
env?: Record<string, string>
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const savedRemoteHooks = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '0'
const remoteSpawn = vi.fn(
async (_opts: { env?: Record<string, string>; envToDelete?: string[] }) => ({
id: 'ssh:ssh-runtime-env@@relay-pty'
})
)
registerSshPtyProvider('ssh-runtime-env', {
spawn: remoteSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
noteRemoteDesktopHostInput: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
(() => ({
agentStatusHooksEnabled: false,
codexSystemDefaultRealHomeEnabled: true
})) as never,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
env: {
FOO: 'bar',
ORCA_PANE_KEY: makePaneKey('tab-remote', leafId),
ORCA_TAB_ID: 'tab-remote',
ORCA_WORKTREE_ID: 'wt-remote'
},
connectionId: 'ssh-runtime-env',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
persistHostSessionBinding: true
})
const spawnOptions = remoteSpawn.mock.calls[0]?.[0]
const env = spawnOptions.env
expect(env).toMatchObject({ FOO: 'bar' })
expect(env?.ORCA_PANE_KEY).toBeUndefined()
expect(env?.ORCA_TAB_ID).toBeUndefined()
expect(env?.ORCA_WORKTREE_ID).toBeUndefined()
expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME')
expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME')
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-runtime-env',
ptyId: 'relay-pty',
leafId,
state: 'attached'
})
)
} finally {
if (savedRemoteHooks === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
} else {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = savedRemoteHooks
}
unregisterSshPtyProvider('ssh-runtime-env')
}
})
it('preserves adopted SSH ownership when runtime binding persistence fails', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const remoteShutdown = vi.fn()
const remoteWrite = vi.fn()
registerSshPtyProvider('ssh-reattach-fail', {
spawn: vi.fn(async () => ({ id: 'ssh:ssh-reattach-fail@@relay-pty', isReattach: true })),
write: remoteWrite,
resize: vi.fn(),
shutdown: remoteShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn(() => {
throw new Error('disk full')
}),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
noteRemoteDesktopHostInput: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-fail',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: 'ssh:ssh-reattach-fail@@relay-pty',
persistHostSessionBinding: true
})
).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/)
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled()
expect(remoteShutdown).not.toHaveBeenCalled()
getPtyWriteListener()(mainWindowIpcEvent, {
id: 'ssh:ssh-reattach-fail@@relay-pty',
data: 'echo remains-routable'
})
expect(remoteWrite).toHaveBeenCalledWith(
'ssh:ssh-reattach-fail@@relay-pty',
'echo remains-routable'
)
unregisterSshPtyProvider('ssh-reattach-fail')
})
it('marks runtime-owned SSH reattach as expired and clears stale local ownership', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const appPtyId = 'ssh:ssh-expired-runtime@@relay-pty'
const remoteWrite = vi.fn()
registerSshPtyProvider('ssh-expired-runtime', {
spawn: vi.fn(async () => {
// The class, not the message: `SSH_SESSION_EXPIRED` is also what a live PTY whose source
// stream needs restoring refuses with, and only this one is host-reported absence.
throw new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: relay-pty`)
}),
write: remoteWrite,
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
noteRemoteDesktopHostInput: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
setPtyOwnership(appPtyId, 'ssh:ssh-expired-runtime')
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-expired-runtime',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: appPtyId,
persistHostSessionBinding: true
})
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-expired-runtime',
'relay-pty',
'expired'
)
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId)
getPtyWriteListener()(mainWindowIpcEvent, { id: appPtyId, data: 'echo nope' })
expect(remoteWrite).not.toHaveBeenCalled()
} finally {
deletePtyOwnership(appPtyId)
unregisterSshPtyProvider('ssh-expired-runtime')
}
})
it('leaves a runtime-owned lease alone when the refusal did not observe the process', async () => {
// The `restoreRequired` twin of the case above: same `SSH_SESSION_EXPIRED` text, opposite
// meaning — the PTY is live and only its source stream needs rebuilding. Expiring the lease
// and dropping ownership erases this client's only record of a running remote process, and
// #9819's sweep reads a PTY it has no record of as one it may SIGKILL on the next connect.
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const appPtyId = 'ssh:ssh-live-runtime@@relay-pty'
const remoteWrite = vi.fn()
registerSshPtyProvider('ssh-live-runtime', {
spawn: vi.fn(async () => {
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: relay-pty`)
}),
write: remoteWrite,
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
noteRemoteDesktopHostInput: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
setPtyOwnership(appPtyId, 'ssh:ssh-live-runtime')
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-live-runtime',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: appPtyId,
persistHostSessionBinding: true
})
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
expect(store.markSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
// Still routable: the client kept its handle on a process that is still running.
getPtyWriteListener()(mainWindowIpcEvent, { id: appPtyId, data: 'echo still-here' })
expect(remoteWrite).toHaveBeenCalledWith(appPtyId, 'echo still-here')
} finally {
deletePtyOwnership(appPtyId)
unregisterSshPtyProvider('ssh-live-runtime')
}
})
})
@@ -1,23 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { spawnMock, openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock-registry'
import { spawnMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { makePaneKey } from '../../shared/stable-pane-id'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import type * as WslManagedCliModule from '../cli/wsl-managed-cli'
import {
SSH_PTY_IDENTITY_MISMATCH_ERROR,
SSH_SESSION_EXPIRED_ERROR
} from '../providers/ssh-pty-errors'
import {
registerPtyHandlers,
registerSshPtyProvider,
clearProviderPtyState,
deletePtyOwnership,
hasPendingRendererSerializerForPaneKey,
setPtyOwnership,
setLocalPtyProvider,
unregisterSshPtyProvider
setLocalPtyProvider
} from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
@@ -72,210 +62,8 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () =>
)
describe('registerPtyHandlers', () => {
const { handlers, mainWindow, mainWindowIpcEvent, getPtyWriteListener } = setupPtyIpcSuite()
const { handlers, mainWindow } = setupPtyIpcSuite()
it('does not clear runtime-owned SSH reattach state on identity mismatch', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const connectionId = 'ssh-identity-runtime'
const appPtyId = `ssh:${connectionId}@@relay-pty`
const remoteWrite = vi.fn()
registerSshPtyProvider(connectionId, {
spawn: vi.fn(async () => {
throw new Error(
`${SSH_SESSION_EXPIRED_ERROR}: relay-pty ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
)
}),
write: remoteWrite,
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
noteRemoteDesktopHostInput: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
setPtyOwnership(appPtyId, toSshExecutionHostId(connectionId))
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId,
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: appPtyId,
persistHostSessionBinding: true
})
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
connectionId,
'relay-pty',
'expired'
)
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(appPtyId)
expect(piClearPtyMock).not.toHaveBeenCalledWith(appPtyId)
getPtyWriteListener()(mainWindowIpcEvent, { id: appPtyId, data: 'echo still-owned' })
expect(remoteWrite).toHaveBeenCalledWith(appPtyId, 'echo still-owned')
} finally {
deletePtyOwnership(appPtyId)
unregisterSshPtyProvider(connectionId)
}
})
it('cleans up fresh runtime-owned SSH spawns when binding persistence fails', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const appPtyId = 'ssh:ssh-fresh-fail@@relay-pty'
const incarnationId = 'incarnation-fresh-fail'
const runtime = new OrcaRuntimeService()
const remoteShutdown = vi.fn(async () => {
// Model the relay's exit callback winning before shutdown resolves.
runtime.onPtyExit(appPtyId, 0, incarnationId)
})
registerSshPtyProvider('ssh-fresh-fail', {
spawn: vi.fn(async () => ({ id: appPtyId, incarnationId })),
write: vi.fn(),
resize: vi.fn(),
shutdown: remoteShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn(() => {
throw new Error('disk full')
}),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = (runtime as unknown as { ptyController: RuntimeSpawnController })
.ptyController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-fresh-fail',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: appPtyId,
persistHostSessionBinding: true
})
).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/)
expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, {
immediate: true,
expectedIncarnationId: incarnationId
})
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled()
expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId)
const internals = runtime as unknown as {
earlyExitedPtyIncarnations: Map<string, string | null>
pendingPtyRegistrationIncarnations: Map<string, string | null>
}
expect(internals.earlyExitedPtyIncarnations.size).toBe(0)
expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0)
} finally {
unregisterSshPtyProvider('ssh-fresh-fail')
}
})
it('maps runtime-owned spawn paneKeys for renderer serializer settlement', async () => {
type RuntimeSpawnController = {
spawn(args: {
@@ -3,11 +3,10 @@ import { onMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority'
import {
deletePtyOwnership,
registerPtyHandlers,
registerSshPtyProvider,
setPtyOwnership,
setLocalPtyProvider,
unregisterSshPtyProvider
setLocalPtyProvider
} from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
@@ -107,84 +106,6 @@ describe('registerPtyHandlers', () => {
expect(inspectProcess).not.toHaveBeenCalled()
}
)
it('lists duplicate SSH relay session ids as distinct app sessions', async () => {
registerPtyHandlers(mainWindow as never)
const shutdownA = vi.fn(async () => undefined)
const shutdownB = vi.fn(async () => undefined)
registerSshPtyProvider('ssh-a', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: shutdownA,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => [
{ id: 'ssh:ssh-a@@pty-1', cwd: '/repo-a', title: 'ssh-a' }
]),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerSshPtyProvider('ssh-b', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: shutdownB,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => [
{ id: 'ssh:ssh-b@@pty-1', cwd: '/repo-b', title: 'ssh-b' }
]),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as {
id: string
cwd: string
title: string
}[]
expect(sessions).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'ssh:ssh-a@@pty-1', cwd: '/repo-a' }),
expect.objectContaining({ id: 'ssh:ssh-b@@pty-1', cwd: '/repo-b' })
])
)
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-a@@pty-1' })
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-b@@pty-1' })
expect(shutdownA).toHaveBeenCalledWith('ssh:ssh-a@@pty-1', {
immediate: true,
keepHistory: false
})
expect(shutdownB).toHaveBeenCalledWith('ssh:ssh-b@@pty-1', {
immediate: true,
keepHistory: false
})
})
it('reports agent ownership through pty:listSessions so the renderer cannot guess it', async () => {
registerPtyHandlers(mainWindow as never)
// Why: the renderer's binding map is empty during restore, so agent ownership is the only
@@ -278,72 +199,6 @@ describe('registerPtyHandlers', () => {
expect(sessions.find((s) => s.id === 'legacy-pty')?.agentOwnership).toBe('unknown')
})
it('kills app-scoped SSH PTY ids through the parsed provider when ownership is not rebuilt', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: localShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const sshShutdown = vi.fn(async () => undefined)
const store = { markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() }
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: sshShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' })
expect(sshShutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', {
immediate: true,
keepHistory: false
})
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
})
it('tombstones app-scoped SSH PTY ids instead of falling back local when ownership and provider are absent', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
@@ -383,52 +238,11 @@ describe('registerPtyHandlers', () => {
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
})
it('ignores fire-and-forget IPC for detached SSH PTYs without a provider', async () => {
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
const provider = {
spawn: vi.fn(async () => ({ id: 'remote-pty' })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(async () => undefined),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
confirmForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
}
registerSshPtyProvider('ssh-1', provider as never)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-1',
env: {}
})
unregisterSshPtyProvider('ssh-1')
it('ignores fire-and-forget IPC for SSH-owned PTYs this process cannot dispatch', () => {
const local = { write: vi.fn(), resize: vi.fn(), sendSignal: vi.fn() }
registerPtyHandlers(mainWindow as never)
setLocalPtyProvider(local as never)
setPtyOwnership('remote-pty', 'ssh:ssh-1')
const listenerFor = (channel: string): ((event: unknown, args: unknown) => void) => {
const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === channel)
if (!call) {
@@ -437,60 +251,50 @@ describe('registerPtyHandlers', () => {
return call[1] as (event: unknown, args: unknown) => void
}
expect(() =>
listenerFor('pty:write')(mainWindowIpcEvent, { id: 'remote-pty', data: 'x' })
).not.toThrow()
expect(() =>
listenerFor('pty:resize')(null, { id: 'remote-pty', cols: 100, rows: 30 })
).not.toThrow()
expect(() => listenerFor('pty:ackColdRestore')(null, { id: 'remote-pty' })).not.toThrow()
expect(() =>
listenerFor('pty:signal')(null, { id: 'remote-pty', signal: 'SIGINT' })
).not.toThrow()
await expect(handlers.get('pty:kill')!(null, { id: 'remote-pty' })).resolves.toBeUndefined()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'terminated')
try {
expect(() =>
listenerFor('pty:write')(mainWindowIpcEvent, { id: 'remote-pty', data: 'x' })
).not.toThrow()
expect(() =>
listenerFor('pty:resize')(null, { id: 'remote-pty', cols: 100, rows: 30 })
).not.toThrow()
expect(() => listenerFor('pty:ackColdRestore')(null, { id: 'remote-pty' })).not.toThrow()
expect(() =>
listenerFor('pty:signal')(null, { id: 'remote-pty', signal: 'SIGINT' })
).not.toThrow()
expect(local.write).not.toHaveBeenCalled()
expect(local.resize).not.toHaveBeenCalled()
expect(local.sendSignal).not.toHaveBeenCalled()
} finally {
deletePtyOwnership('remote-pty')
}
})
it('returns idle process inspection results for detached SSH PTYs without a provider', async () => {
const provider = {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
it('returns idle process inspection results for SSH-owned PTYs without a provider', async () => {
const local = {
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
confirmForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
confirmForegroundProcess: vi.fn()
}
registerSshPtyProvider('ssh-1', provider as never)
registerPtyHandlers(mainWindow as never)
setLocalPtyProvider(local as never)
setPtyOwnership('remote-pty', 'ssh:ssh-1')
unregisterSshPtyProvider('ssh-1')
await expect(handlers.get('pty:hasChildProcesses')!(null, { id: 'remote-pty' })).resolves.toBe(
false
)
await expect(
handlers.get('pty:getForegroundProcess')!(null, { id: 'remote-pty' })
).resolves.toBeNull()
await expect(
handlers.get('pty:confirmForegroundProcess')!(null, { id: 'remote-pty' })
).resolves.toBeNull()
expect(provider.hasChildProcesses).not.toHaveBeenCalled()
expect(provider.getForegroundProcess).not.toHaveBeenCalled()
expect(provider.confirmForegroundProcess).not.toHaveBeenCalled()
try {
await expect(
handlers.get('pty:hasChildProcesses')!(null, { id: 'remote-pty' })
).resolves.toBe(false)
await expect(
handlers.get('pty:getForegroundProcess')!(null, { id: 'remote-pty' })
).resolves.toBeNull()
await expect(
handlers.get('pty:confirmForegroundProcess')!(null, { id: 'remote-pty' })
).resolves.toBeNull()
expect(local.hasChildProcesses).not.toHaveBeenCalled()
expect(local.getForegroundProcess).not.toHaveBeenCalled()
expect(local.confirmForegroundProcess).not.toHaveBeenCalled()
} finally {
deletePtyOwnership('remote-pty')
}
})
it('preserves unavailable process inspection results from the provider', async () => {
const inspectProcess = vi.fn(async () => ({
+1 -54
View File
@@ -4,7 +4,7 @@ import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import { _setWslCachesForTests } from '../wsl'
import { acquireWatcherRemovalGate } from './watcher-removal-gate'
import { registerPtyHandlers, registerSshPtyProvider } from './pty'
import { registerPtyHandlers } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -260,59 +260,6 @@ describe('registerPtyHandlers', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('does not probe or forward local WSL cwd evidence for SSH fallback spawns', async () => {
const sshSpawn = vi.fn(async () => ({ id: 'ssh-wsl-looking-cwd' }))
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/a',
cwdFallback: 'worktree',
connectionId: 'ssh-1',
worktreeId: 'repo-1::C:\\Users\\alice\\repo',
shellOverride: 'wsl.exe',
projectRuntime: {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
projectId: 'repo-1',
distro: 'Ubuntu-24.04',
reason: 'project-override',
cacheKey: 'repo-1:wsl'
}
}
})
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
expect(sshSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/a' }))
expect(sshSpawn).toHaveBeenCalledWith(
expect.not.objectContaining({ prevalidatedCwd: expect.anything() })
)
})
it('preserves a POSIX WSL cwd when the distro probe is inconclusive', async () => {
const originalPlatform = process.platform
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-inconclusive-cwd' })
@@ -11,7 +11,7 @@ import {
makePaneSpawnReservationKey,
reservePaneSpawn
} from './pty/pane/spawn-reservation'
import { registerPtyHandlers, registerSshPtyProvider } from './pty'
import { registerPtyHandlers } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -60,57 +60,6 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () =>
describe('registerPtyHandlers', () => {
const { handlers, mainWindow, mainWindowIpcEvent } = setupPtyIpcSuite()
it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => {
const spawn = vi.fn(async () => ({ id: 'remote-pty' }))
registerSshPtyProvider('ssh-1', {
spawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn(),
acknowledgeDataEvent: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-1',
env: { EXISTING: '1' }
})
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
EXISTING: '1',
ORCA_TERMINAL_HANDLE: 'term_remote'
})
})
)
expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith(
'remote-pty',
'term_remote'
)
})
it('refreshes captured native Agent Teams env for renderer PTY spawns', async () => {
const leafId = '11111111-1111-4111-8111-111111111111'
const runtime = {
@@ -416,81 +365,6 @@ describe('registerPtyHandlers', () => {
baseEnv: expect.any(Object)
})
})
it('restores daemon launch identity without minting renderer authority on reattach', async () => {
const incarnationId = 'ssh-reattach-incarnation'
const spawn = vi.fn(async () => ({
id: 'ssh-reattach',
incarnationId,
isReattach: true as const,
launchAgent: 'codex' as const
}))
registerSshPtyProvider('ssh-reattach-1', {
spawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn(),
acknowledgeDataEvent: vi.fn()
} as never)
const registerPty = vi.fn()
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty
}
const tabId = 'tab-reattach'
const leafId = '77777777-7777-4777-8777-777777777777'
const worktreeId = 'repo-1::/tmp/reattach'
registerPtyHandlers(mainWindow as never, runtime as never)
const result = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, {
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-1',
worktreeId,
tabId,
leafId,
env: {
ORCA_PANE_KEY: makePaneKey(tabId, leafId),
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
},
launchConfig: {
agentCommand: 'codex --model gpt-5',
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
}
})) as { id: string; isReattach?: boolean; launchConfig?: unknown }
expect(result).toMatchObject({ id: 'ssh-reattach', isReattach: true })
expect(result.launchConfig).toBeUndefined()
expect(registerPty).toHaveBeenCalledWith(
'ssh-reattach',
worktreeId,
'ssh-reattach-1',
expect.objectContaining({
tabId,
leafId,
incarnationId,
providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' }
}),
undefined
)
})
it('reuses the runtime background handle in local PTY spawn env', async () => {
type RuntimeSpawnController = {
spawn(args: {
+1 -219
View File
@@ -1,14 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { toSshExecutionHostId } from '../../shared/execution-host'
import {
registerPtyHandlers,
deletePtyOwnership,
setPtyOwnership,
getLocalPtyProvider,
registerSshPtyProvider,
unregisterSshPtyProvider
getLocalPtyProvider
} from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
@@ -162,219 +159,4 @@ describe('stopping a PTY whose SSH provider is unregistered', () => {
)
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalled()
})
it('records provider-confirmed absence when the exit event was missed', async () => {
const connectionId = 'ssh-confirmed-absent'
const ptyId = 'ssh-confirmed-absent-pty'
const provider = {
onExit: vi.fn(() => () => {}),
shutdown: vi.fn(async () => {}),
listProcesses: vi.fn(async () => [])
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, toSshExecutionHostId(connectionId))
try {
const { controller, runtime } = installController()
await expect(controller.stopAndWait(ptyId)).resolves.toBe(true)
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, 0, undefined)
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalled()
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
}
})
it('waits for an observed SSH exit to reach the runtime record before confirming', async () => {
const connectionId = 'ssh-observed-exit'
const ptyId = 'ssh-observed-exit-pty'
const exitListeners = new Set<(payload: { id: string }) => void>()
const provider = {
onExit: vi.fn((listener: (payload: { id: string }) => void) => {
exitListeners.add(listener)
return () => exitListeners.delete(listener)
}),
shutdown: vi.fn(async () => {
for (const listener of exitListeners) {
listener({ id: ptyId })
}
}),
listProcesses: vi.fn(async () => [])
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, toSshExecutionHostId(connectionId))
try {
const { controller, runtime } = installController()
let recordExit: (recorded: boolean) => void = () => {}
const waitForPtyExitRecord = vi.fn(
(_ptyId: string, _timeoutMs: number) =>
new Promise<boolean>((resolve) => (recordExit = resolve))
)
Object.assign(runtime, { waitForPtyExitRecord })
let settled = false
const stop = controller.stopAndWait(ptyId).then((stopped) => {
settled = true
return stopped
})
await vi.waitFor(() => expect(waitForPtyExitRecord).toHaveBeenCalled())
const [waitedPtyId, timeoutMs] = waitForPtyExitRecord.mock.calls[0] ?? []
expect(waitedPtyId).toBe(ptyId)
expect(timeoutMs).toBeGreaterThan(0)
expect(timeoutMs).toBeLessThanOrEqual(10_000)
expect(settled).toBe(false)
recordExit(true)
await expect(stop).resolves.toBe(true)
// The intake delivers the exit itself; the stop must not fabricate a second one.
expect(runtime.onPtyExit).not.toHaveBeenCalled()
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
}
})
it('caps the observed-exit wait at the caller deadline', async () => {
const connectionId = 'ssh-observed-exit-deadline'
const ptyId = 'ssh-observed-exit-deadline-pty'
const exitListeners = new Set<(payload: { id: string }) => void>()
const provider = {
onExit: vi.fn((listener: (payload: { id: string }) => void) => {
exitListeners.add(listener)
return () => exitListeners.delete(listener)
}),
shutdown: vi.fn(async () => {
for (const listener of exitListeners) {
listener({ id: ptyId })
}
}),
listProcesses: vi.fn(async () => [])
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, toSshExecutionHostId(connectionId))
try {
const { controller, runtime } = installController()
const waitForPtyExitRecord = vi.fn(async (_ptyId: string, _timeoutMs: number) => false)
Object.assign(runtime, { waitForPtyExitRecord })
await expect(controller.stopAndWait(ptyId, { deadlineMs: Date.now() + 2_000 })).resolves.toBe(
true
)
const timeoutMs = waitForPtyExitRecord.mock.calls[0]?.[1]
expect(timeoutMs).toBeLessThanOrEqual(2_000)
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
}
})
it('reports lost contact when a registered provider drops during the stop', async () => {
const connectionId = 'ssh-mid-stop-drop'
const ptyId = 'ssh-mid-stop-pty'
const provider = {
onExit: vi.fn(() => () => {}),
shutdown: vi.fn(async () => {
throw new Error('relay disconnected during stop')
})
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, toSshExecutionHostId(connectionId))
try {
const { controller, runtime } = installController()
await expect(controller.stopAndWait(ptyId)).resolves.toBe(false)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
ptyId,
'relay disconnected during stop'
)
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
}
})
it('preserves lost-contact evidence when fire-and-forget shutdown rejects', async () => {
const connectionId = 'ssh-async-kill-drop'
const ptyId = 'ssh-async-kill-pty'
const provider = {
onExit: vi.fn(() => () => {}),
shutdown: vi.fn(async () => {
throw new Error('relay disconnected during kill')
})
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, toSshExecutionHostId(connectionId))
try {
const { controller, runtime } = installController()
expect(controller.kill(ptyId)).toBe(true)
await vi.waitFor(() =>
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
ptyId,
'relay disconnected during kill'
)
)
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined)
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
}
})
it('isolates a failed SSH inventory from healthy providers', async () => {
const failedConnectionId = 'ssh-inventory-failed'
const healthyConnectionId = 'ssh-inventory-healthy'
const failedPtyId = 'ssh-inventory-failed-pty'
const healthyPtyId = 'ssh-inventory-healthy-pty'
registerSshPtyProvider(failedConnectionId, {
listProcesses: vi.fn(async () => {
throw new Error('inventory transport failed')
})
} as never)
registerSshPtyProvider(healthyConnectionId, {
listProcesses: vi.fn(async () => [{ id: healthyPtyId }])
} as never)
setPtyOwnership(failedPtyId, toSshExecutionHostId(failedConnectionId))
setPtyOwnership(healthyPtyId, toSshExecutionHostId(healthyConnectionId))
try {
const { controller, runtime } = installController()
await expect(controller.listProcesses()).resolves.toEqual(
expect.arrayContaining([{ id: healthyPtyId }])
)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
failedPtyId,
'inventory transport failed'
)
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalledWith(
healthyPtyId,
expect.anything()
)
} finally {
deletePtyOwnership(failedPtyId)
deletePtyOwnership(healthyPtyId)
unregisterSshPtyProvider(failedConnectionId)
unregisterSshPtyProvider(healthyConnectionId)
}
})
it('reports a provider-observed survivor as live', async () => {
const connectionId = 'ssh-still-live'
const ptyId = 'ssh-still-live-pty'
const provider = {
onExit: vi.fn(() => () => {}),
shutdown: vi.fn(async () => {}),
listProcesses: vi.fn(async () => [{ id: ptyId }])
}
registerSshPtyProvider(connectionId, provider as never)
setPtyOwnership(ptyId, toSshExecutionHostId(connectionId))
try {
const { controller, runtime } = installController()
await expect(controller.stopAndWait(ptyId)).resolves.toBe(false)
expect(runtime.markPtyLivenessLive).toHaveBeenCalledWith(ptyId)
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
}
})
})
@@ -1,11 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-errors'
import {
registerPtyHandlers,
registerSshPtyProvider,
unregisterSshPtyProvider,
deletePtyOwnership,
setPtyOwnership,
restorePtyIncarnation
@@ -65,16 +62,6 @@ function createKillStore() {
}
}
function sshProviderStub(shutdown: () => Promise<void>) {
return {
shutdown: vi.fn(shutdown),
onExit: vi.fn(() => () => {}),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => [])
} as never
}
function installController(handlers: Map<string, never>) {
const runtime = {
setPtyController: vi.fn(),
@@ -119,38 +106,6 @@ describe('undelivered SSH stops', () => {
}
}
// The leak: the shutdown died on the transport, the verdict is correctly `unverifiable`, and
// before this fix nothing retried — the remote shell survived forever.
it('records the stop when the shutdown RPC rejects with a transport failure', async () => {
const store = createKillStore()
registerSshPtyProvider(
'ssh-1',
sshProviderStub(async () => {
throw new Error('socket closed')
})
)
setPtyOwnership(SCOPED_PTY_ID, 'ssh:ssh-1')
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-a')
const { kill, runtime } = install(store)
try {
expect(kill(SCOPED_PTY_ID)).toBe(true)
await vi.waitFor(() => expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalled())
expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith(
'ssh-1',
'pty-7',
expect.objectContaining({ incarnationId: 'inc-a', attempts: 0 })
)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
SCOPED_PTY_ID,
'socket closed'
)
} finally {
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership(SCOPED_PTY_ID)
}
})
// The offline close: no provider is registered, so the relay is never even asked. The lease is
// tombstoned locally, and without the record the remote shell has nothing left to retire it.
it('records the stop when no provider is registered to deliver it', async () => {
@@ -171,84 +126,6 @@ describe('undelivered SSH stops', () => {
}
})
it('records nothing when the shutdown RPC is delivered', async () => {
const store = createKillStore()
registerSshPtyProvider(
'ssh-1',
sshProviderStub(async () => {})
)
setPtyOwnership(SCOPED_PTY_ID, 'ssh:ssh-1')
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-c')
const { kill } = install(store)
try {
expect(kill(SCOPED_PTY_ID)).toBe(true)
await vi.waitFor(() => expect(store.markSshRemotePtyLease).toHaveBeenCalled())
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
// And it does not retire one either. A resolved RPC is not a death certificate — the relay
// answers identically for a PTY it never had — so retirement is left to the replay, which
// only acts on a live inventory. Clearing here would be the mechanism by which a still-valid
// order is destroyed on an unconfirmed success.
expect(store.clearSshRemotePtyKillIntent).not.toHaveBeenCalled()
} finally {
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership(SCOPED_PTY_ID)
}
})
// Worktree sleep stops through stopAndWait and marks those stops reversible: when one does not
// land, the pane stays live and the user keeps using it. A durable order recorded here would come
// back on a later handshake and kill that terminal out from under them.
it('records nothing for a reversible stopAndWait that could not be delivered', async () => {
const store = createKillStore()
registerSshPtyProvider(
'ssh-1',
sshProviderStub(async () => {
throw new Error('socket closed')
})
)
setPtyOwnership(SCOPED_PTY_ID, 'ssh:ssh-1')
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-e')
const { stopAndWait } = install(store)
try {
await expect(stopAndWait(SCOPED_PTY_ID, { keepHistory: true })).resolves.toBe(false)
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
} finally {
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership(SCOPED_PTY_ID)
}
})
// The path the UI actually takes. `window.api.pty.kill` -> `pty:kill`, a separate implementation
// from the runtime controller, and the one #12447 describes.
it('records the stop when the renderer pty:kill shutdown rejects', async () => {
const store = createKillStore()
registerSshPtyProvider(
'ssh-1',
sshProviderStub(async () => {
throw new Error('socket closed')
})
)
setPtyOwnership(SCOPED_PTY_ID, 'ssh:ssh-1')
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-r1')
install(store)
try {
await expect(handlers.get('pty:kill')!(null, { id: SCOPED_PTY_ID })).rejects.toThrow(
'socket closed'
)
expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith(
'ssh-1',
'pty-7',
expect.objectContaining({ incarnationId: 'inc-r1' })
)
} finally {
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership(SCOPED_PTY_ID)
}
})
it('records the stop when renderer pty:kill finds no provider to deliver it', async () => {
const store = createKillStore()
setPtyOwnership(SCOPED_PTY_ID, 'ssh:ssh-1')
@@ -267,80 +144,6 @@ describe('undelivered SSH stops', () => {
}
})
// Pane hibernation is the reversible caller on this IPC and is the only one passing keepHistory.
it('records nothing for a renderer pty:kill that keeps history', async () => {
const store = createKillStore()
registerSshPtyProvider(
'ssh-1',
sshProviderStub(async () => {
throw new Error('socket closed')
})
)
setPtyOwnership(SCOPED_PTY_ID, 'ssh:ssh-1')
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-r3')
install(store)
try {
await expect(
handlers.get('pty:kill')!(null, { id: SCOPED_PTY_ID, keepHistory: true })
).rejects.toThrow('socket closed')
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
} finally {
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership(SCOPED_PTY_ID)
}
})
it('records nothing while a reversible stop owns the PTY', async () => {
const store = createKillStore()
registerSshPtyProvider(
'ssh-1',
sshProviderStub(async () => {
throw new Error('socket closed')
})
)
setPtyOwnership(SCOPED_PTY_ID, 'ssh:ssh-1')
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f')
const { kill, runtime } = install(store)
const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null)
try {
kill(SCOPED_PTY_ID)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
} finally {
settleStop(false)
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership(SCOPED_PTY_ID)
}
})
it('records nothing while a reversible stop owns the PTY and a restart stop joins it', async () => {
const store = createKillStore()
registerSshPtyProvider(
'ssh-1',
sshProviderStub(async () => {
throw new Error('socket closed')
})
)
setPtyOwnership(SCOPED_PTY_ID, 'ssh:ssh-1')
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f')
const { kill, runtime } = install(store)
const settleSleep = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null)
const settleRestart = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'replaced', null)
try {
kill(SCOPED_PTY_ID)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
} finally {
settleRestart(false)
settleSleep(false)
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership(SCOPED_PTY_ID)
}
})
it('records nothing for a local PTY, which has no later host to ask', async () => {
const store = createKillStore()
setPtyOwnership('local-pty', 'local')
@@ -356,28 +159,6 @@ describe('undelivered SSH stops', () => {
}
})
// A legacy id with no incarnation has no fence, and an unfenced order can only be guessed at.
it('records nothing for a legacy id whose PTY incarnation was never learned', async () => {
const store = createKillStore()
registerSshPtyProvider(
'ssh-1',
sshProviderStub(async () => {
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}-transport`)
})
)
setPtyOwnership('ssh:ssh-1@@pty-8', 'ssh:ssh-1')
const { kill } = install(store)
try {
kill('ssh:ssh-1@@pty-8')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
} finally {
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership('ssh:ssh-1@@pty-8')
}
})
// A `pty2:` id is epoch-scoped, so it names one process even when the incarnation was never
// learned: an offline close across a relaunch still owes the kill.
it('records the stop for an epoch-scoped id whose incarnation was never learned', () => {
@@ -2,13 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { spawnMock } from './pty-ipc-mock-registry'
import { makeDeferred } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import {
registerPtyHandlers,
registerSshPtyProvider,
setLocalPtyProvider,
rebindLocalProviderListeners,
getLocalPtyProvider
} from './pty'
import { registerPtyHandlers, setLocalPtyProvider, rebindLocalProviderListeners } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -144,150 +138,6 @@ describe('registerPtyHandlers', () => {
expect(result.id).toBe(daemonSpawn.mock.calls[0]?.[0].sessionId)
expect(spawnMock).not.toHaveBeenCalled()
})
it('does not wait on the desktop startup barrier for SSH spawns or kills', async () => {
const barrier = makeDeferred()
const awaitLocalPtyStartup = vi.fn(() => barrier.promise)
const sshSpawn = vi.fn(async () => ({ id: 'remote-pty' }))
const sshShutdown = vi.fn()
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: sshShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
undefined,
{ awaitLocalPtyStartup }
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-1',
env: {}
})
).resolves.toEqual(expect.objectContaining({ id: 'remote-pty' }))
await handlers.get('pty:kill')!(null, { id: 'remote-pty' })
expect(awaitLocalPtyStartup).not.toHaveBeenCalled()
expect(sshSpawn).toHaveBeenCalledTimes(1)
expect(sshShutdown).toHaveBeenCalledWith('remote-pty', {
immediate: true,
keepHistory: false
})
})
it('lists sessions from both local and SSH providers', async () => {
registerPtyHandlers(mainWindow as never)
const sshListProcesses = vi.fn(async () => [
{ id: 'remote-pty', cwd: '/remote', title: 'ssh-shell' },
{ id: 'closing-pty', cwd: '/remote', title: 'ssh-shell', exiting: true as const }
])
const sshShutdown = vi.fn(async () => undefined)
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: sshShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
onData: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: sshListProcesses,
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as {
id: string
cwd: string
title: string
}[]
expect(sshListProcesses).toHaveBeenCalled()
expect(sessions).toEqual(
expect.arrayContaining([
expect.objectContaining({ cwd: '/remote', id: 'remote-pty', title: 'ssh-shell' }),
expect.objectContaining({ id: 'closing-pty', exiting: true })
])
)
expect(sessions.find((session) => session.id === 'remote-pty')).not.toHaveProperty('exiting')
await handlers.get('pty:kill')!(null, { id: 'remote-pty' })
expect(sshShutdown).toHaveBeenCalledWith('remote-pty', {
immediate: true,
keepHistory: false
})
})
it('starts local and SSH session inventories concurrently', async () => {
let resolveLocal!: (sessions: { id: string; cwd: string; title: string }[]) => void
const localSessions = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => {
resolveLocal = resolve
})
vi.spyOn(getLocalPtyProvider(), 'listProcesses').mockReturnValue(localSessions)
registerPtyHandlers(mainWindow as never)
let resolveSsh!: (sessions: { id: string; cwd: string; title: string }[]) => void
const sshSessions = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => {
resolveSsh = resolve
})
const sshListProcesses = vi.fn(() => sshSessions)
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: sshListProcesses,
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const pendingInventory = handlers.get('pty:listSessions')!(null, undefined)
expect(sshListProcesses).toHaveBeenCalledTimes(1)
resolveLocal([])
resolveSsh([])
await pendingInventory
})
it('reports authoritative snapshot capability with the owning provider context', async () => {
const capabilityProvider = {
authoritativeIds: new Set(['current-pty']),
@@ -337,31 +187,6 @@ describe('registerPtyHandlers', () => {
await expect(pending).resolves.toEqual([{ id: 'restored-local-pty', authoritative: true }])
})
it('does not gate remote snapshot capability on local provider startup', async () => {
const awaitLocalPtyProviderStartup = vi.fn(() => new Promise<void>(() => {}))
registerSshPtyProvider('ssh-1', {
canProvideAuthoritativeBufferSnapshot: () => false
} as never)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
undefined,
{ awaitLocalPtyProviderStartup }
)
const result = await handlers.get('pty:getAuthoritativeBufferSnapshotCapabilities')?.(null, {
ids: ['remote:environment@@pty-1', 'ssh:ssh-1@@pty-2']
})
expect(awaitLocalPtyProviderStartup).not.toHaveBeenCalled()
expect(result).toEqual([
{ id: 'remote:environment@@pty-1', authoritative: false },
{ id: 'ssh:ssh-1@@pty-2', authoritative: false }
])
})
it('answers false, not null, for a resolved provider with no snapshot capability', async () => {
// Null is never cached, so missing optional methods must resolve false.
registerPtyHandlers(mainWindow as never)
@@ -1,8 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { makeDeferred } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { registerPtyHandlers, registerSshPtyProvider } from './pty'
import { ptyOwnership } from './pty/provider/ownership-state'
import { registerPtyHandlers } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -117,16 +116,6 @@ describe('registerPtyHandlers daemon-swap-window presence', () => {
await expect(pending).resolves.toBe(true)
})
it('pty:hasPty answers SSH-owned ids from their provider without waiting on the local swap', async () => {
const barrier = makeDeferred()
const sshHasPty = vi.fn((id: string) => id === 'ssh:ssh-1@@pty-2')
registerSshPtyProvider('ssh-1', { hasPty: sshHasPty } as never)
registerWithStartupBarrier(barrier.promise)
await expect(handlers.get('pty:hasPty')!(null, { id: 'ssh:ssh-1@@pty-2' })).resolves.toBe(true)
expect(sshHasPty).toHaveBeenCalledWith('ssh:ssh-1@@pty-2')
})
it('runtime controller hasPty answers null, not false, while the local provider swap is in flight', async () => {
const barrier = makeDeferred()
const controller = installRuntimeControllerWithBarrier(barrier.promise)
@@ -167,21 +156,6 @@ describe('registerPtyHandlers daemon-swap-window presence', () => {
expect(controller?.hasPty('remote:environment@@pty-1')).toBe(null)
})
it('runtime controller hasPty answers SSH-owned ids without waiting on the local swap', () => {
const barrier = makeDeferred()
const sshHasPty = vi.fn((id: string) => id === 'ssh-live-pty')
registerSshPtyProvider('ssh-1', { hasPty: sshHasPty } as never)
ptyOwnership.set('ssh-live-pty', 'ssh:ssh-1')
try {
const controller = installRuntimeControllerWithBarrier(barrier.promise)
expect(controller.hasPty('ssh-live-pty')).toBe(true)
expect(sshHasPty).toHaveBeenCalledWith('ssh-live-pty')
} finally {
ptyOwnership.delete('ssh-live-pty')
}
})
it('pty:inspectProcess defers a restored daemon id until the provider swap lands instead of answering from the non-owning provider', async () => {
const barrier = makeDeferred()
registerWithStartupBarrier(barrier.promise)
@@ -219,24 +193,6 @@ describe('registerPtyHandlers daemon-swap-window presence', () => {
})
})
it('pty:inspectProcess answers SSH-owned ids from their provider without waiting on the local swap', async () => {
const barrier = makeDeferred()
const sshInspect = vi.fn(async () => ({
foregroundProcess: 'ssh-codex',
hasChildProcesses: true
}))
registerSshPtyProvider('ssh-1', {
hasPty: (id: string) => id === 'ssh:ssh-1@@pty-2',
inspectProcess: sshInspect
} as never)
registerWithStartupBarrier(barrier.promise)
await expect(
handlers.get('pty:inspectProcess')!(null, { id: 'ssh:ssh-1@@pty-2' })
).resolves.toEqual({ foregroundProcess: 'ssh-codex', hasChildProcesses: true })
expect(sshInspect).toHaveBeenCalledWith('ssh:ssh-1@@pty-2')
})
it('keeps the in-process provider authoritative when no startup barrier is configured', async () => {
// Headless/orcad installs the daemon before registerPtyHandlers and passes
// no barrier; the installed provider is then the sole owner (#12393) and
+1 -7
View File
@@ -17,13 +17,7 @@ export type {
} from './pty/host-env/types'
export { resolveCodexHomeAfterManagedAuthReadiness } from './pty/host-env/codex-home'
export { buildPtyHostEnv } from './pty/host-env/assembly'
export {
registerSshPtyProvider,
unregisterSshPtyProvider,
getSshPtyProvider,
getLocalPtyProvider,
setLocalPtyProvider
} from './pty/provider/registry'
export { getLocalPtyProvider, setLocalPtyProvider } from './pty/provider/registry'
export { clearProviderPtyState, clearPtyOwnershipForConnection } from './pty/provider/state-cleanup'
export {
rebindLocalProviderListeners,
+4 -21
View File
@@ -1,11 +1,9 @@
import type { IPtyProvider } from '../../../providers/types'
import { SshPtyOutputIntake } from '../../ssh-pty-output-intake'
import {
cancelSshPtySourceDelivery,
installSshPtyOutputIntake,
publishSshPtySourceAck
} from '../../ssh-pty-output-intake-registry'
import { sshProvidersByGeneration } from '../provider/registry'
import { setSshOutputIntakeCleanup, sshOutputIntakeCleanup } from '../provider/listener-lifecycle'
import type { PtyIpcSession } from '../session'
@@ -50,24 +48,10 @@ export function installSessionSshOutputIntake(session: PtyIpcSession): void {
})
session.finalizePtyExitForRenderer(event)
},
pauseProvider: (generation, id) => {
const provider = sshProvidersByGeneration.get(generation) as
| (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean })
| undefined
if (!provider?.hasPtyDeliveryPauseAdapter?.()) {
return false
}
provider.pauseProducer?.(id)
return true
},
resumeProvider: (generation, id) =>
sshProvidersByGeneration.get(generation)?.resumeProducer?.(id),
closeProvider: (generation, reason) => {
const provider = sshProvidersByGeneration.get(generation)
;(
provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined
)?.closeOutputIntake?.(reason)
},
// Why: this process registers no SSH PTY provider, so no generation names a live producer.
pauseProvider: () => false,
resumeProvider: () => {},
closeProvider: () => {},
resetModelForMigration: (_generation, id) =>
session.runtime?.resetPtyModelAfterMigrationFailure(id),
onGenerationClosed: (providerGeneration) => {
@@ -85,7 +69,6 @@ export function installSessionSshOutputIntake(session: PtyIpcSession): void {
session.pendingOverflowMarkedPtys.delete(id)
}
}
sshProvidersByGeneration.delete(providerGeneration)
},
publishSourceAck: publishSshPtySourceAck,
cancelSourceDelivery: cancelSshPtySourceDelivery
@@ -5,9 +5,7 @@ import {
_resetColorQueryReplyColorsForTest,
getLocalPtyProvider,
publishColorQueryReplyColors,
registerSshPtyProvider,
setLocalPtyProvider,
unregisterSshPtyProvider
setLocalPtyProvider
} from './registry'
class RecordingProvider extends LocalPtyProvider {
@@ -26,25 +24,21 @@ describe('PTY owner colour publication', () => {
afterEach(() => {
_resetColorQueryReplyColorsForTest()
unregisterSshPtyProvider('ssh-colors')
setLocalPtyProvider(originalLocal)
})
it('pushes every change to each owner, and the latest to owners that register later', () => {
it('pushes every change to the owner, and the latest to an owner installed later', () => {
const local = new RecordingProvider()
setLocalPtyProvider(local)
const early = new RecordingProvider()
registerSshPtyProvider('ssh-colors', early)
// Why nothing yet: pushing "unknown" would wipe the theme a surviving daemon still holds.
expect([...local.pushes, ...early.pushes]).toEqual([])
expect(local.pushes).toEqual([])
publishColorQueryReplyColors(DARK)
publishColorQueryReplyColors(LIGHT)
const late = new RecordingProvider()
registerSshPtyProvider('ssh-colors', late)
setLocalPtyProvider(late)
expect(local.pushes).toEqual([DARK, LIGHT])
expect(early.pushes).toEqual([DARK, LIGHT])
expect(late.pushes).toEqual([LIGHT])
})
@@ -10,11 +10,14 @@ import {
hasPtyProviderForInspection,
PtyHostNotDispatchableError,
registeredPtyProviders,
registerSshPtyProvider,
setLocalPtyProvider,
tryGetProviderForPty,
unregisterSshPtyProvider
tryGetProviderForPty
} from './registry'
import {
isHostReportedPtyAbsenceError,
isObservedPtyExitEvidence,
isPtyAlreadyGoneError
} from './liveness'
function answeringProvider(): IPtyProvider {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the routed calls below only touch these members.
@@ -61,39 +64,46 @@ describe('PTY registry keyed by execution host', () => {
const originalLocal = getLocalPtyProvider()
afterEach(() => {
setLocalPtyProvider(originalLocal)
unregisterSshPtyProvider('box 1')
deletePtyOwnership('wt-1@@abcd1234')
})
it('lists this machine and each relay under its host id', () => {
it('dispatches only this machine; every other host is not dispatchable here', () => {
const local = answeringProvider()
const relay = answeringProvider()
setLocalPtyProvider(local)
registerSshPtyProvider('box 1', relay)
expect(registeredPtyProviders()).toEqual([
{ provider: local, hostId: 'local' },
{ provider: relay, hostId: 'ssh:box%201' }
])
expect(registeredPtyProviders()).toEqual([{ provider: local, hostId: 'local' }])
expect(getProvider('local')).toBe(local)
expect(getProvider('ssh:box%201')).toBe(relay)
expect(() => getProvider('ssh:box%201')).toThrow(PtyHostNotDispatchableError)
// Why: the renderer matches this prefix to show the reconnect affordance.
expect(() => getProvider('ssh:box-2')).toThrow('No PTY provider for connection "box-2"')
expect(() => getProvider('runtime:env-1')).toThrow(PtyHostNotDispatchableError)
expect(() => getProvider('runtime:env-1')).toThrow(
'PTY host "runtime:env-1" is not dispatchable from this process'
)
})
it('routes by the recorded owner first, then by the host the id names', () => {
const relay = answeringProvider()
registerSshPtyProvider('box 1', relay)
it('keeps an SSH-owned PTY attributed to its host, never to this machine', () => {
const local = answeringProvider()
setLocalPtyProvider(local)
setPtyOwnership('wt-1@@abcd1234', 'ssh:box%201')
expect(getProviderForPty('wt-1@@abcd1234')).toBe(relay)
expect(() => getProviderForPty('wt-1@@abcd1234')).toThrow(PtyHostNotDispatchableError)
expect(getPtySshConnectionId('wt-1@@abcd1234')).toBe('box 1')
expect(getProviderForPty('ssh:box%201@@pty-1')).toBe(relay)
unregisterSshPtyProvider('box 1')
// Why: a detached relay keeps its PTYs attributed to it, never to this machine.
expect(tryGetProviderForPty('ssh:box%201@@pty-1')).toBeUndefined()
expect(hasPtyProviderForInspection('ssh:box%201@@pty-1')).toBe(false)
expect(getPtySshConnectionId('ssh:box%201@@pty-1')).toBe('box 1')
})
// Why: an undispatchable SSH PTY is unverifiable, so no predicate may read it as gone or exited.
it('never treats the not-dispatchable error as evidence the PTY is gone', () => {
let error: unknown
try {
getProviderForPty('ssh:box%201@@pty-1')
} catch (caught) {
error = caught
}
expect(error).toBeInstanceOf(PtyHostNotDispatchableError)
expect(isPtyAlreadyGoneError(error)).toBe(false)
expect(isHostReportedPtyAbsenceError(error)).toBe(false)
expect(isObservedPtyExitEvidence(error)).toBe(false)
})
})
+21 -60
View File
@@ -16,12 +16,9 @@ import { getPtyExecutionHost } from '../../../../shared/terminal-execution-host'
// Routes PTY operations by the execution host that runs the PTY; this machine is `local`.
export let localProvider: IPtyProvider = new LocalPtyProvider()
/** SSH relay providers by SSH target id; address them through their `ssh:` host id. */
export const sshProviders = new Map<string, IPtyProvider>()
export const sshProvidersByGeneration = new Map<number, IPtyProvider>()
let colorQueryReplyColors: TerminalOscColorQueryReplyColors | null = null
// Why push to every owner: each process that owns PTYs (in-process, daemon, relay) answers
// Why push to every owner: each process that owns PTYs (in-process or daemon) answers
// OSC 10/11 itself, so a theme change must reach it before its next query, not at spawn.
// Why never push "unknown": a daemon outlives the app and keeps the last run's theme.
function pushColorQueryReplyColors(provider: IPtyProvider): void {
@@ -36,7 +33,7 @@ function pushColorQueryReplyColors(provider: IPtyProvider): void {
}
export function publishColorQueryReplyColors(colors: TerminalOscColorQueryReplyColors): void {
// Why: a re-install republishes unchanged colours; that must not re-notify every daemon and relay.
// Why: a re-install republishes unchanged colours; that must not re-notify every daemon.
if (colorQueryReplyColorsEqual(colorQueryReplyColors, colors)) {
return
}
@@ -56,19 +53,25 @@ export type RegisteredPtyProvider = {
}
export function registeredPtyProviders(): RegisteredPtyProvider[] {
return [
{ provider: localProvider, hostId: LOCAL_EXECUTION_HOST_ID },
...Array.from(sshProviders, ([connectionId, provider]) => ({
provider,
hostId: toSshExecutionHostId(connectionId)
}))
]
return [{ provider: localProvider, hostId: LOCAL_EXECUTION_HOST_ID }]
}
function ptyHostNotDispatchableMessage(hostId: ResolvedPtyHost): string {
const connectionId = hostId === 'foreign' ? null : getSshTargetIdForExecutionHost(hostId)
if (connectionId === null) {
return `PTY host "${hostId}" is not dispatchable from this process`
}
// Why: this surfaces verbatim in `terminal create`; keep the prefix — the renderer matches it.
return (
`No PTY provider for connection "${connectionId}": the SSH relay for this host is not attached ` +
'(reconnecting or disconnected). Wait for the host to reconnect, or use Reconnect on the SSH target.'
)
}
/** Only this machine's PTYs are driven here; a paired runtime drives its own over runtime RPC. */
export class PtyHostNotDispatchableError extends Error {
constructor(readonly hostId: ResolvedPtyHost) {
// Why: a paired runtime drives its own PTYs over runtime RPC; this process has no provider for them.
super(`PTY host "${hostId}" is not dispatchable from this process`)
super(ptyHostNotDispatchableMessage(hostId))
this.name = 'PtyHostNotDispatchableError'
}
}
@@ -77,20 +80,7 @@ export function getProvider(hostId: ExecutionHostId): IPtyProvider {
if (hostId === LOCAL_EXECUTION_HOST_ID) {
return localProvider
}
const connectionId = getSshTargetIdForExecutionHost(hostId)
if (connectionId === null) {
throw new PtyHostNotDispatchableError(hostId)
}
const provider = sshProviders.get(connectionId)
if (!provider) {
// Why the suffix: this surfaces verbatim in `terminal create` on a reconnecting SSH host; the
// bare id told the caller nothing about what to do. Keep the prefix — the renderer matches it.
throw new Error(
`No PTY provider for connection "${connectionId}": the SSH relay for this host is not attached ` +
'(reconnecting or disconnected). Wait for the host to reconnect, or use Reconnect on the SSH target.'
)
}
return provider
throw new PtyHostNotDispatchableError(hostId)
}
/** 'foreign' = a PTY that runs off this machine on a host its id cannot name. */
@@ -118,7 +108,7 @@ export function getPtySshConnectionId(ptyId: string): string | null {
return hostId === 'foreign' ? null : getSshTargetIdForExecutionHost(hostId)
}
/** Whether this process can hold a provider for the host at all (this machine or an SSH relay). */
/** This machine, or an SSH host whose PTYs read as retryable rather than definitively foreign. */
export function isDispatchablePtyHost(hostId: ResolvedPtyHost): boolean {
return (
hostId === LOCAL_EXECUTION_HOST_ID ||
@@ -135,12 +125,8 @@ export function getProviderForPty(ptyId: string): IPtyProvider {
}
export function hasPtyProviderForInspection(ptyId: string): boolean {
// Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise repeated IPC errors.
if (resolvePtyExecutionHost(ptyId) === LOCAL_EXECUTION_HOST_ID) {
return true
}
const connectionId = getPtySshConnectionId(ptyId)
return connectionId !== null && sshProviders.has(connectionId)
// Why: process inspection is background polling; other hosts read as idle, not repeated IPC errors.
return resolvePtyExecutionHost(ptyId) === LOCAL_EXECUTION_HOST_ID
}
export function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string {
@@ -169,31 +155,6 @@ export function closeStartupQueryAuthorityForPty(ptyId: string): void {
}
}
/** Register an SSH PTY provider for a connection. */
export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void {
sshProviders.set(connectionId, provider)
pushColorQueryReplyColors(provider)
const generation = provider.providerGeneration
if (generation !== undefined && Number.isSafeInteger(generation) && generation > 0) {
sshProvidersByGeneration.set(generation, provider)
}
}
/** Remove an SSH PTY provider when a connection is closed. */
export function unregisterSshPtyProvider(connectionId: string): void {
const provider = sshProviders.get(connectionId)
const generation = provider?.providerGeneration
if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) {
sshProvidersByGeneration.delete(generation)
}
sshProviders.delete(connectionId)
}
/** Get the SSH PTY provider for a connection (for dispose on cleanup). */
export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined {
return sshProviders.get(connectionId)
}
/** Get the installed PTY provider (for direct access in tests/runtime).
* After daemon init this may be a DaemonPtyAdapter/DaemonPtyRouter, not LocalPtyProvider;
* callers needing LocalPtyProvider-specific methods must type-narrow or import the class. */
@@ -6,9 +6,7 @@ import {
getLocalPtyProvider,
getPtyRendererDeliveryDebugSnapshot,
registerPtyHandlers,
registerSshPtyProvider,
setPtyOwnership,
unregisterSshPtyProvider
setPtyOwnership
} from '../pty'
import { registerHeadlessPtyRuntime } from './register-headless-runtime'
import { onMock } from '../pty-ipc-mock-registry'
@@ -108,20 +106,19 @@ describe('PTY registration without renderer delivery', () => {
expect(daemon.pauseProducer).not.toHaveBeenCalled()
})
it('routes local and SSH control to their providers, including attach and buffer snapshots', async () => {
const remote = installObservableDaemonTestProvider()
const remoteProvider = getLocalPtyProvider()
registerSshPtyProvider('ssh-a', remoteProvider)
it('routes control to the local provider and never sends an SSH id there', async () => {
const local = installObservableDaemonTestProvider()
const localProvider = getLocalPtyProvider()
const localClear = vi.spyOn(localProvider, 'clearBuffer')
const remoteClear = vi.spyOn(remoteProvider, 'clearBuffer')
const attach = vi.spyOn(localProvider, 'attach').mockResolvedValue({})
const runtime = new OrcaRuntimeService()
const setController = vi.spyOn(runtime, 'setPtyController')
const remoteId = 'ssh:ssh-a@@remote-pty'
local.getBufferSnapshot.mockResolvedValue({ data: 'local history', cols: 80, rows: 24 })
remote.getBufferSnapshot.mockResolvedValue({ data: 'remote history', cols: 100, rows: 30 })
local.getBufferSnapshot.mockResolvedValue({
data: 'local history',
cols: 80,
rows: 24
})
await registerHeadlessPtyRuntime(runtime)
const controller = setController.mock.calls[0]?.[0]
if (!controller) {
@@ -129,27 +126,20 @@ describe('PTY registration without renderer delivery', () => {
}
expect(controller.write('daemon-pty', 'local input', 'driving')).toBe(true)
expect(controller.write(remoteId, 'remote input', 'driving')).toBe(true)
await controller.clearBuffer?.('daemon-pty')
await controller.clearBuffer?.(remoteId)
await expect(controller.attach?.('daemon-pty')).resolves.toBe(true)
await expect(controller.attach?.(remoteId)).resolves.toBe(false)
await expect(controller.serializeProviderBuffer?.('daemon-pty')).resolves.toMatchObject({
data: 'local history'
})
await expect(controller.serializeProviderBuffer?.(remoteId)).resolves.toMatchObject({
data: 'remote history'
})
expect(local.write).toHaveBeenCalledExactlyOnceWith('daemon-pty', 'local input')
expect(remote.write).toHaveBeenCalledExactlyOnceWith(remoteId, 'remote input')
expect(localClear).toHaveBeenCalledExactlyOnceWith('daemon-pty')
expect(remoteClear).toHaveBeenCalledExactlyOnceWith(remoteId)
expect(attach).toHaveBeenCalledExactlyOnceWith('daemon-pty')
unregisterSshPtyProvider('ssh-a')
expect(controller.write(remoteId, 'disconnected input', 'driving')).toBe(false)
expect(controller.write(remoteId, 'remote input', 'driving')).toBe(false)
await expect(controller.attach?.(remoteId)).resolves.toBe(false)
await expect(controller.probePtyLiveness?.(remoteId)).resolves.toBeNull()
expect(local.write).toHaveBeenCalledTimes(1)
expect(attach).toHaveBeenCalledTimes(1)
})
it('rejects renderer input when no renderer owns the registration', async () => {
@@ -1,60 +1,19 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { sshProviders } from '../provider/registry'
import { describe, expect, it } from 'vitest'
import { listProcessesWithHostScopeFromRuntimeController } from './inventory-operations'
import type { PtyRuntimeControllerDeps } from './controller-deps'
/**
* `hostScopeCensusIsComplete` discounts a `runtime:` host in `omittedHostIds` on the strength of
* one fact about this process: it has no paired-runtime PTY provider, so it never queried that
* host and never owed it coverage. This file pins the producer side of that fact.
*
* What it catches: a new branch here that spells a queried host `runtime:`. Every id this
* function emits is built by `toSshExecutionHostId` or is `LOCAL_EXECUTION_HOST_ID`, so a third
* shape is the observable form of "a runtime host can now answer an inventory" — at which point
* the client predicate would start calling a genuine gap complete.
*
* What it does NOT catch, so do not lean on it: a runtime-backed transport registered under an
* SSH connection id still reports as `ssh:` and passes, which is fine — the predicate only
* discounts the `runtime:` spelling. The consolidation moving the SSH path onto orcad is expected
* to look exactly like that. The other route into `queriedHostIds` is separately fenced to
* `kind === 'ssh'` in `orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts`.
* host and never owed it coverage. This file pins the producer side of that fact: this process
* queries only its own PTYs, so a `runtime:` spelling here is the breakage that matters.
*/
describe('the hosts a PTY inventory can report having queried', () => {
afterEach(() => {
sshProviders.clear()
})
it('emits only local and ssh spellings, never a paired-runtime one', async () => {
const listProcesses = vi.fn(async () => [])
sshProviders.set('box-1', { listProcesses } as never)
// A connection id shaped like an environment uuid still has to come back `ssh:`; the spelling
// is what the gate keys on, so a `runtime:` id appearing here is the breakage that matters.
sshProviders.set('a2478221-1d5c-4603-b8bf-b6b728eac9df', { listProcesses } as never)
it('emits only this machine, never a paired-runtime host', async () => {
const { hostIds } = await listProcessesWithHostScopeFromRuntimeController({
runtime: null
} as unknown as PtyRuntimeControllerDeps)
expect(hostIds).toContain('ssh:a2478221-1d5c-4603-b8bf-b6b728eac9df')
expect(new Set(hostIds.map((hostId) => parseExecutionHostId(hostId)?.kind))).toEqual(
new Set(['local', 'ssh'])
)
})
it('drops a provider that threw rather than reporting its host as queried', async () => {
sshProviders.set('box-live', { listProcesses: vi.fn(async () => []) } as never)
sshProviders.set('box-down', {
listProcesses: vi.fn(async () => {
throw new Error('relay unavailable')
})
} as never)
const { hostIds } = await listProcessesWithHostScopeFromRuntimeController({
runtime: { markPtyLivenessUnverifiable: vi.fn() }
} as unknown as PtyRuntimeControllerDeps)
expect(hostIds).toContain('ssh:box-live')
expect(hostIds).not.toContain('ssh:box-down')
expect(hostIds).toEqual(['local'])
})
})
@@ -1,12 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures'
import {
registerPtyHandlers,
registerSshPtyProvider,
unregisterSshPtyProvider,
getLocalPtyProvider
} from './pty'
import { registerPtyHandlers } from './pty'
import { isCommandOnPath } from './preflight-command-exec'
import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection'
import type * as FsPromises from 'node:fs/promises'
@@ -245,55 +240,4 @@ describe('desktop Qoder execution-host selection', () => {
expect(wslProbe).not.toHaveBeenCalled()
})
})
it.each(['modern-only', 'legacy-only', 'missing', 'disposed'] as const)(
'lets SSH own detection and refuse absent contact: %s',
async (host) => {
const remoteSpawn = setupDaemonAdapter()
registerSshPtyProvider('ssh-qoder-review', getLocalPtyProvider())
const localSpawn = setupDaemonAdapter()
const request = vi
.fn()
.mockResolvedValue({ agents: [host === 'modern-only' ? 'qoder' : 'qodercli'] })
mux.mockReturnValue(
host === 'missing' ? null : { isDisposed: () => host === 'disposed', request }
)
registerPtyHandlers(mainWindow)
try {
const pending = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/remote/folder',
worktreeId: 'repo-ssh::/remote/folder',
connectionId: 'ssh-qoder-review',
launchAgent: 'qoder',
command: "qodercli '--resume' 'same remote id'"
})
if (host === 'missing' || host === 'disposed') {
await expect(pending).rejects.toThrow('execution host connection')
expect(remoteSpawn).not.toHaveBeenCalled()
expect(request).not.toHaveBeenCalled()
} else {
await pending
expect(remoteSpawn).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/remote/folder',
command: `${host === 'modern-only' ? 'qoder' : 'qodercli'} '--resume' 'same remote id'`
})
)
expect(request).toHaveBeenCalledWith('preflight.detectAgents', {
commands: [
{ id: 'qodercli', cmd: 'qodercli' },
{ id: 'qoder', cmd: 'qoder' }
]
})
}
expect(localSpawn).not.toHaveBeenCalled()
expect(nativeProbe).not.toHaveBeenCalled()
expect(wslProbe).not.toHaveBeenCalled()
expect(mux).toHaveBeenCalledWith('ssh-qoder-review')
} finally {
unregisterSshPtyProvider('ssh-qoder-review')
}
}
)
})
-1
View File
@@ -12,7 +12,6 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider)
vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider)
vi.mock('./pty', () => mocks.pty)
vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch)
@@ -12,7 +12,6 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider)
vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider)
vi.mock('./pty', () => mocks.pty)
vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch)
@@ -12,7 +12,6 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider)
vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider)
vi.mock('./pty', () => mocks.pty)
vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch)
@@ -12,7 +12,6 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider)
vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider)
vi.mock('./pty', () => mocks.pty)
vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch)
-16
View File
@@ -1,12 +1,9 @@
import type { Mock } from 'vitest'
import type { SshPtySourceFrame } from '../providers/ssh-pty-source-frame'
// Declared shapes for the SSH IPC mock registry. The suites assert on recorded calls rather
// than argument types, so every spy stays `Mock` (vi.fn()'s untyped default) — naming the
// shapes here is what keeps declaration emit from reaching into @vitest/spy internals.
export type SshIpcTestSource = SshPtySourceFrame
/** Callbacks production code hands a manager mock at construction / setCallbacks time. */
export type MockCallbacksRef = { current: unknown }
@@ -45,17 +42,6 @@ export type SshChannelMultiplexerMock = {
probeLiveness: Mock
}
export type SshPtyProviderMock = {
onData: Mock
onExit: Mock
onReplay: Mock
attach: Mock
attachForReconnect: Mock
shutdown: Mock
listProcesses: Mock
providerGeneration: number
}
export type SshPortForwardManagerMock = {
addForward: Mock
updateForward: Mock
@@ -77,7 +63,6 @@ export type SshIpcMockState = {
mockAcceptSshPtyOutputData: Mock
mockAcceptSshPtyOutputExit: Mock
mockMux: SshChannelMultiplexerMock
mockPtyProvider: SshPtyProviderMock
mockFsProvider: Record<string, unknown>
mockGitProvider: Record<string, unknown>
mockRegisterSshGitProvider: Mock
@@ -100,7 +85,6 @@ export type SshIpcMockModules = {
sshConnectionStore: SshIpcMockModule
sshConnectionManager: SshIpcMockModule
sshChannelMultiplexer: SshIpcMockModule
sshPtyProvider: SshIpcMockModule
sshFilesystemProvider: SshIpcMockModule
pty: SshIpcMockModule
sshFilesystemDispatch: SshIpcMockModule
-24
View File
@@ -1,6 +1,5 @@
import { vi } from 'vitest'
import type { SshIpcMockModules, SshIpcMockState, SshIpcMocks } from './ssh-ipc-mock-shapes'
import { errorMessage } from '../../shared/error-message'
export type { SshIpcMocks }
@@ -49,16 +48,6 @@ export function createSshIpcMocks(): SshIpcMocks {
notify: vi.fn(),
probeLiveness: vi.fn().mockResolvedValue(false)
},
mockPtyProvider: {
onData: vi.fn(),
onExit: vi.fn(),
onReplay: vi.fn(),
attach: vi.fn(),
attachForReconnect: vi.fn().mockResolvedValue({}),
shutdown: vi.fn(),
listProcesses: vi.fn(async () => []),
providerGeneration: 0
},
mockFsProvider: {},
mockGitProvider: {},
mockRegisterSshGitProvider: vi.fn(),
@@ -94,7 +83,6 @@ export function createSshIpcMocks(): SshIpcMocks {
mockAcceptSshPtyOutputData,
mockAcceptSshPtyOutputExit,
mockMux,
mockPtyProvider,
mockFsProvider,
mockGitProvider,
mockRegisterSshGitProvider,
@@ -190,15 +178,6 @@ export function createSshIpcMocks(): SshIpcMocks {
}
}
},
sshPtyProvider: {
isSshPtyNotFoundError: (err: unknown) => errorMessage(err).includes('not found'),
SshPtyProvider: class MockSshPtyProvider {
constructor(_targetId: unknown, _mux: unknown, _env: unknown, providerGeneration: number) {
mockPtyProvider.providerGeneration = providerGeneration
return mockPtyProvider
}
}
},
sshFilesystemProvider: {
SshFilesystemProvider: class MockSshFilesystemProvider {
constructor() {
@@ -207,13 +186,10 @@ export function createSshIpcMocks(): SshIpcMocks {
}
},
pty: {
registerSshPtyProvider: vi.fn(),
unregisterSshPtyProvider: vi.fn(),
clearPtyOwnershipForConnection: vi.fn(),
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
setPtyOwnership: vi.fn(),
getSshPtyProvider: vi.fn(),
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
isCurrentPtyExit: vi.fn().mockReturnValue(true),
isRendererPtyOutputPaused: vi.fn().mockReturnValue(false)
+3 -54
View File
@@ -1,20 +1,9 @@
import { vi } from 'vitest'
import type { Mock } from 'vitest'
import { registerSshHandlers, resetSshHandlerStateForTests } from './ssh'
import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session'
import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract'
import {
clearProviderPtyState,
deletePtyOwnership,
getSshPtyProvider,
getPtyIdsForConnection
} from './pty'
import { clearProviderPtyState, deletePtyOwnership, getPtyIdsForConnection } from './pty'
import type { SshIpcMocks } from './ssh-ipc-module-mocks'
import type {
SshConnectionManagerMock,
SshIpcTestSource,
SshPortForwardManagerMock
} from './ssh-ipc-mock-shapes'
import type { SshConnectionManagerMock, SshPortForwardManagerMock } from './ssh-ipc-mock-shapes'
/** Lease + consumer-recovery slice of the app store the SSH handlers write through. */
export type SshLeaseStoreMock = {
@@ -40,8 +29,6 @@ export type SshLeaseStoreMock = {
export type MockBrowserWindow = { isDestroyed: () => boolean; webContents: { send: Mock } }
export type SshIpcHarness = {
relayBuildId: string
ipcTestSource: SshIpcTestSource
handlers: Map<string, (_event: unknown, args: unknown) => unknown>
mockStore: SshLeaseStoreMock
mockWindow: MockBrowserWindow
@@ -64,23 +51,12 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness {
mockAcceptSshPtyOutputData,
mockAcceptSshPtyOutputExit,
mockMux,
mockPtyProvider,
mockRegisterSshGitProvider,
mockPortForwardManager,
mockPortScannerCallbacks,
mockNextConnectionManagers,
mockNextPortForwardManagers
} = mocks
const relayBuildId = '0.1.0+ipc-test'
const ipcTestSource = {
relayPtyId: 'remote-pty',
spanId: 'ipc-test-delivery:0:5',
clientGeneration: 1,
ownerGeneration: 1,
deliveryToken: 'ipc-test-delivery',
sourceStartSu: 0,
sourceEndSu: 5
} as const
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
const mockStore = {
getRepos: () => [],
@@ -174,32 +150,8 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness {
mockMux.onNotification.mockReset()
mockMux.onNotificationByMethod.mockReset().mockReturnValue(() => {})
mockMux.onDispose.mockReset().mockReturnValue(() => {})
mockMux.request.mockReset().mockImplementation((method: string) =>
Promise.resolve(
method === 'pty.openClient'
? {
protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION,
serverBuildId: relayBuildId,
clientGeneration: 1,
role: 'session-owner',
ownerGeneration: 1,
ownerLease: 'ipc-test-owner',
resumed: false,
capabilities: {
outputFlowControl: { version: 1, windowSu: DEFAULT_PTY_SOURCE_WINDOW_SU }
}
}
: {}
)
)
mockMux.request.mockReset().mockResolvedValue({})
mockMux.probeLiveness.mockReset().mockResolvedValue(false)
mockPtyProvider.onData.mockReset()
mockPtyProvider.onExit.mockReset()
mockPtyProvider.onReplay.mockReset()
mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({})
mockPtyProvider.shutdown.mockReset()
mockPtyProvider.listProcesses.mockReset().mockResolvedValue([])
mockPtyProvider.providerGeneration = 0
mockRegisterSshGitProvider.mockReset()
mockPortForwardManager.addForward.mockReset()
mockPortForwardManager.updateForward.mockReset()
@@ -212,7 +164,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness {
mockPortForwardManager.callbacksRef.current = null
powerMonitorOnMock.mockReset()
powerMonitorOffMock.mockReset()
vi.mocked(getSshPtyProvider).mockReset()
vi.mocked(getPtyIdsForConnection).mockReset().mockReturnValue([])
vi.mocked(clearProviderPtyState).mockReset()
vi.mocked(deletePtyOwnership).mockReset()
@@ -221,8 +172,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness {
}
return {
relayBuildId,
ipcTestSource,
handlers,
mockStore,
mockWindow,
@@ -12,7 +12,6 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider)
vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider)
vi.mock('./pty', () => mocks.pty)
vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch)
-1
View File
@@ -12,7 +12,6 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider)
vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider)
vi.mock('./pty', () => mocks.pty)
vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch)
-1
View File
@@ -12,7 +12,6 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider)
vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider)
vi.mock('./pty', () => mocks.pty)
vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch)
+1 -3
View File
@@ -67,9 +67,7 @@ vi.mock('../memory/pty-registry', () => ({
listRegisteredPtys: listRegisteredPtysMock
}))
vi.mock('./pty', () => ({
getSshPtyProvider: vi.fn()
}))
vi.mock('./pty', () => ({}))
// Why: snapshot persistence does real file I/O; covered in workspace-cleanup-snapshot-ipc.test.ts.
vi.mock('../workspace-cleanup-scan-snapshot', () => ({
@@ -9,12 +9,10 @@ import {
getSshGitProviderMock,
deleteWorktreeHistoryDirMock,
killAllProcessesForWorktreeMock,
clearProviderPtyStateMock,
getSshPtyProviderMock
clearProviderPtyStateMock
} from './worktrees-test-module-mocks'
import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { mockKnownFeatureWorktree } from './worktrees-test-fixtures'
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
@@ -102,10 +100,8 @@ vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).pty
const anyCatalogVersion = { epoch: expect.any(String), sequence: expect.any(Number) }
describe('registerWorktreeHandlers', () => {
let runtimeStub: WorktreeRuntimeStub
beforeEach(() => {
runtimeStub = setupWorktreeHandlers()
setupWorktreeHandlers()
})
it('coalesces concurrent deletes for the same worktree id', async () => {
@@ -295,9 +291,9 @@ describe('registerWorktreeHandlers', () => {
expect(callOrder).toEqual(['preflight', 'kill', 'git'])
})
// Regression: `repoId::path` ids repeat across hosts, so an SSH delete used to reach the
// runtime's same-id local (or other-connection) terminals and stop them.
it('fences an SSH worktree delete PTY sweep to the owning connection', async () => {
// Why: this process holds no SSH PTY provider, so an SSH delete must refuse rather than sweep
// the same-id local workspace's terminals.
it('refuses an SSH worktree delete it cannot stop terminals for', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -307,7 +303,6 @@ describe('registerWorktreeHandlers', () => {
connectionId: 'conn-1',
worktreeBaseRef: null
}
const sshPtyProvider = { id: 'ssh-pty-provider' } as never
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue({
@@ -324,20 +319,13 @@ describe('registerWorktreeHandlers', () => {
removeWorktree: vi.fn().mockResolvedValue({}),
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
})
getSshPtyProviderMock.mockReturnValue(sshPtyProvider)
getEffectiveHooksFromConfigMock.mockReturnValue(null)
await handlers['worktrees:remove'](null, { worktreeId: 'repo-ssh::/remote/feature-wt' })
await expect(
handlers['worktrees:remove'](null, { worktreeId: 'repo-ssh::/remote/feature-wt' })
).rejects.toThrow('PTY provider unavailable for worktree deletion')
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith('repo-ssh::/remote/feature-wt', {
runtime: runtimeStub,
resolvedWorktreeId: 'repo-ssh::/remote/feature-wt',
resolvedConnectionId: 'conn-1',
localProvider: sshPtyProvider,
onPtyStopped: clearProviderPtyStateMock,
requirePhysicalStop: true,
includeLocalRegistry: false
})
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
})
// The local counterpart still identifies itself by exact id so a selector that resolves
@@ -520,49 +508,6 @@ describe('registerWorktreeHandlers', () => {
).rejects.toThrow(/cannot prove process stop/)
expect(removeWorktreeMock).not.toHaveBeenCalled()
})
it('deletes nested SSH worktrees only through their owning provider', async () => {
const { approved } = mockNestedWorktrees()
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'remote',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: null
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
const remotePlan = approved.map((item) => ({
...item,
path: item.path.replace('/workspace', '/remote')
}))
let remaining = [...remotePlan]
const remoteRemove = vi.fn(async (path: string) => {
remaining = remaining.filter((item) => item.path !== path)
return {}
})
getSshGitProviderMock.mockReturnValue({
listWorktrees: vi.fn(async () => remaining),
removeWorktree: remoteRemove,
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
})
getEffectiveHooksFromConfigMock.mockReturnValue(null)
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-ssh::/remote/parent',
hostId: 'ssh:conn-1',
force: true,
approvedNestedWorktrees: remotePlan
})
expect(remoteRemove.mock.calls.map(([path]) => path)).toEqual(
remotePlan.map((item) => item.path)
)
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(listWorktreesMock).not.toHaveBeenCalled()
for (const call of killAllProcessesForWorktreeMock.mock.calls) {
expect(call[1]).toMatchObject({ resolvedConnectionId: 'conn-1', includeLocalRegistry: false })
}
})
it('waits for background checkout deletion before starting any ancestor', async () => {
const { approved } = mockNestedWorktrees()
let finish: () => void = () => {}
+15 -87
View File
@@ -14,7 +14,6 @@ import {
killAllProcessesForWorktreeMock,
clearProviderPtyStateMock,
getLocalPtyProviderMock,
getSshPtyProviderMock,
parseOrcaYamlMock,
hasHooksFileMock
} from './worktrees-test-module-mocks'
@@ -128,7 +127,6 @@ describe('registerWorktreeHandlers', () => {
getLocalPtyProviderMock.mockReturnValue(ptyProvider)
// Why: a removed/disconnected SSH target has no live provider; forgetLocal must not reach for one.
getSshGitProviderMock.mockReturnValue(undefined)
getSshPtyProviderMock.mockReturnValue(undefined)
const result = await handlers['worktrees:forgetLocal'](null, { worktreeId })
@@ -175,32 +173,6 @@ describe('registerWorktreeHandlers', () => {
expect(removeWorktreeMock).not.toHaveBeenCalled()
})
it('sweeps a connected SSH owner through its PTY provider', async () => {
const worktreeId = 'repo-gone::/workspace/feature-wt'
const sshProvider = {} as never
store.getRepos.mockReturnValue([])
store.getRepo.mockReturnValue(undefined)
store.getWorktreeMeta.mockReturnValue({ hostId: 'ssh:ssh-live' })
getSshPtyProviderMock.mockReturnValue(sshProvider)
await expect(
handlers['worktrees:forgetLocal'](null, { worktreeId, hostId: 'ssh:ssh-live' })
).resolves.toEqual({})
expect(getSshPtyProviderMock).toHaveBeenCalledWith('ssh-live')
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, {
runtime: runtimeStub,
resolvedWorktreeId: worktreeId,
resolvedConnectionId: 'ssh-live',
localProvider: sshProvider,
onPtyStopped: clearProviderPtyStateMock,
closeStructuredSessions: true,
includeProviderInventory: true,
includeLocalRegistry: false
})
expect(getLocalPtyProviderMock).not.toHaveBeenCalled()
})
it('forgets an ownerless workspace after its repo is already gone', async () => {
const worktreeId = 'repo-gone::/workspace/feature-wt'
const worktreePath = '/workspace/feature-wt'
@@ -257,7 +229,6 @@ describe('registerWorktreeHandlers', () => {
store.getRepo.mockReturnValue(repo)
// The orphan case forget-local exists for: the meta row is lost, so only the repo still records ownership.
store.getWorktreeMeta.mockReturnValue(undefined)
getSshPtyProviderMock.mockReturnValue(undefined)
getLocalPtyProviderMock.mockReturnValue({} as never)
await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({})
@@ -310,7 +281,6 @@ describe('registerWorktreeHandlers', () => {
await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({})
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local')
expect(getSshPtyProviderMock).not.toHaveBeenCalled()
})
it('rejects forgetting a folder project root', async () => {
@@ -363,7 +333,8 @@ describe('worktree execution-host disambiguation', () => {
setupWorktreeHandlers()
})
it('uses the workspace host when duplicate repo ids exist across local and SSH', async () => {
// Why the refusal: this process stops no SSH terminals, so reaching it proves the SSH row was chosen.
it('routes to the workspace host when duplicate repo ids exist across local and SSH', async () => {
const localRepo = {
id: 'repo-shared',
path: '/local/repo',
@@ -402,12 +373,14 @@ describe('worktree execution-host disambiguation', () => {
store.getRepos.mockReturnValue([localRepo, sshRepo])
getSshGitProviderMock.mockReturnValue(provider)
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-shared::/remote/feature-wt',
hostId: 'ssh:conn-1'
})
await expect(
handlers['worktrees:remove'](null, {
worktreeId: 'repo-shared::/remote/feature-wt',
hostId: 'ssh:conn-1'
})
).rejects.toThrow('PTY provider unavailable for worktree deletion')
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
expect(provider.removeWorktree).not.toHaveBeenCalled()
expect(removeWorktreeMock).not.toHaveBeenCalled()
})
@@ -534,58 +507,13 @@ describe('worktree execution-host disambiguation', () => {
store.getRepos.mockReturnValue([repo])
getSshGitProviderMock.mockReturnValue(provider)
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-escaped::/remote/feature-wt'
})
await expect(
handlers['worktrees:remove'](null, {
worktreeId: 'repo-escaped::/remote/feature-wt'
})
).rejects.toThrow('PTY provider unavailable for worktree deletion')
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
})
it('tears down the remote session when an ownerless remote worktree is deleted', async () => {
const sshRepo = {
id: 'repo-1',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: null
}
const provider = {
listWorktrees: vi.fn().mockResolvedValue([
{
path: sshRepo.path,
head: 'main',
branch: 'main',
isBare: false,
isMainWorktree: true
},
{
path: '/remote/feature-wt',
head: 'feature',
branch: 'feature',
isBare: false,
isMainWorktree: false
}
]),
removeWorktree: vi.fn().mockResolvedValue(undefined),
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
}
store.getRepo.mockReturnValue(sshRepo)
store.getRepos.mockReturnValue([sshRepo])
getSshGitProviderMock.mockReturnValue(provider)
// Why: no WorktreeMeta means removal cannot read the owner back; the repo is the only host source.
store.getWorktreeMeta.mockReturnValue(undefined)
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/remote/feature-wt'
})
// The whole point: without the repo's host the ownerless row would clear the local session instead.
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(
'repo-1::/remote/feature-wt',
'ssh:conn-1'
)
expect(provider.removeWorktree).not.toHaveBeenCalled()
})
it('fails closed when duplicate repo ids are deleted without a host', async () => {
@@ -217,41 +217,4 @@ describe('worktree mutation scan-generation ordering', () => {
expect(witness.after).toBeGreaterThan(witness.during ?? Infinity)
expect(replySequence(result)).toBeGreaterThanOrEqual(witness.after ?? Infinity)
})
it('bumps the generation before the first step after an SSH git worktree remove', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1'
}
const witness: GenerationWitness = {}
const provider = {
listWorktrees: vi.fn(async () => [
{ ...createdRow('/remote/repo', 'main'), isMainWorktree: true },
createdRow('/remote/feature-wt', 'feature')
]),
removeWorktree: vi.fn(async () => {
witness.during = getLocalWorktreeScanGeneration(repo.id)
}),
worktreeIsClean: vi.fn(async () => ({ clean: true }))
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
runtimeStub.acquireFileWatcherRemoval.mockResolvedValue({
finish: vi.fn(async () => witnessAfter(witness, repo.id))
})
const result: unknown = await handlers['worktrees:remove'](null, {
worktreeId: 'repo-ssh::/remote/feature-wt',
force: true
})
expect(provider.removeWorktree).toHaveBeenCalledOnce()
expect(witness.after).toBeGreaterThan(witness.during ?? Infinity)
expect(replySequence(result)).toBeGreaterThanOrEqual(witness.after ?? Infinity)
})
})
@@ -26,8 +26,7 @@ import {
recordRemovalSnapshotPruneMock,
killAllProcessesForWorktreeMock,
clearProviderPtyStateMock,
getLocalPtyProviderMock,
getSshPtyProviderMock
getLocalPtyProviderMock
} from './worktrees-test-module-mocks'
import {
handlers,
@@ -720,7 +719,8 @@ describe('registerWorktreeHandlers', () => {
// Folder projects can be SSH-backed, and folder workspace ids are `repoId::path::workspace:<uuid>`
// — reusable across hosts — so the sweep must name the owning connection.
it('fences an SSH folder workspace PTY sweep to the owning connection', async () => {
const sshPtyProvider = { id: 'ssh-pty-provider' } as never
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an identity marker; the mocked sweep never calls into it.
const localPtyProvider = { id: 'local-pty-provider' } as never
const worktreeId = 'repo-folder::/remote/folder::workspace:child-1'
store.getRepo.mockReturnValue({
id: 'repo-folder',
@@ -731,20 +731,19 @@ describe('registerWorktreeHandlers', () => {
kind: 'folder',
connectionId: 'conn-1'
})
getSshPtyProviderMock.mockReturnValue(sshPtyProvider)
getLocalPtyProviderMock.mockReturnValue(localPtyProvider)
// One global meta key can describe the same-id local copy; the resolved repo still owns this delete.
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' }))
await handlers['worktrees:remove'](null, { worktreeId })
expect(getSshPtyProviderMock).toHaveBeenCalledWith('conn-1')
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, {
runtime: runtimeStub,
resolvedWorktreeId: worktreeId,
resolvedConnectionId: 'conn-1',
localProvider: sshPtyProvider,
localProvider: localPtyProvider,
onPtyStopped: clearProviderPtyStateMock,
includeProviderInventory: true,
includeProviderInventory: false,
includeLocalRegistry: false,
closeStructuredSessions: true
})
@@ -782,7 +781,6 @@ describe('registerWorktreeHandlers', () => {
includeLocalRegistry: false,
closeStructuredSessions: true
})
expect(getSshPtyProviderMock).not.toHaveBeenCalled()
})
// A scan the host answered is the only evidence that ever retires an off-host WorktreeMeta row,
// so it must retire that worktree's hook-status rows too, or they stay stranded in last-status.json.
@@ -272,86 +272,6 @@ describe('registerWorktreeHandlers', () => {
)
})
it('runs the archive hook before removing an SSH worktree', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: null
}
const callOrder: string[] = []
runtimeStub.closeFileWatchersForRemoval.mockImplementationOnce(async () => {
callOrder.push('watchers')
})
const provider = {
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/repo',
head: 'main',
branch: 'main',
isBare: false,
isMainWorktree: true
},
{
path: '/remote/feature-wt',
head: 'feature',
branch: 'feature',
isBare: false,
isMainWorktree: false
}
]),
removeWorktree: vi.fn().mockImplementation(async () => {
callOrder.push('remove')
}),
worktreeIsClean: vi.fn().mockImplementation(async () => {
callOrder.push('preflight')
return { clean: true }
}),
execNonInteractive: vi.fn().mockImplementation(async () => {
callOrder.push('archive')
return { stdout: '', stderr: '', exitCode: 0, timedOut: false }
})
}
const fsProvider = {
readFile: vi.fn().mockResolvedValue({
content: 'scripts:\n archive: echo archived\n',
isBinary: false
})
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-ssh::/remote/feature-wt'
})
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml')
expect(provider.execNonInteractive).toHaveBeenCalledWith(
'/bin/bash',
['-lc', 'echo archived'],
'/remote/feature-wt',
120_000,
undefined,
expect.objectContaining({
ORCA_ROOT_PATH: '/remote/repo',
ORCA_WORKTREE_PATH: '/remote/feature-wt'
})
)
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
expect(runtimeStub.closeFileWatchersForRemoval).toHaveBeenCalledWith(
'/remote/feature-wt',
'conn-1'
)
expect(callOrder).toEqual(['archive', 'preflight', 'watchers', 'remove'])
expect(runHookMock).not.toHaveBeenCalled()
})
it('runs SSH archive hooks before failing dirty non-force removal', async () => {
const repo = {
id: 'repo-ssh',
@@ -415,63 +335,6 @@ describe('registerWorktreeHandlers', () => {
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('skips SSH dirty preflight for force removal', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: null
}
const provider = {
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/repo',
head: 'main',
branch: 'main',
isBare: false,
isMainWorktree: true
},
{
path: '/remote/feature-wt',
head: 'feature',
branch: 'feature',
isBare: false,
isMainWorktree: false
}
]),
removeWorktree: vi.fn().mockResolvedValue(undefined),
worktreeIsClean: vi.fn(),
execNonInteractive: vi.fn().mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false
})
}
const fsProvider = {
readFile: vi.fn().mockResolvedValue({
content: 'scripts:\n archive: echo archived\n',
isBinary: false
})
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-ssh::/remote/feature-wt',
force: true
})
expect(provider.worktreeIsClean).not.toHaveBeenCalled()
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', true)
})
// Was "continues SSH worktree removal when the archive hook fails" (#19334): it now refuses.
it('refuses SSH worktree removal when the remote archive hook exits non-zero', async () => {
const repo = {
@@ -586,124 +449,6 @@ describe('registerWorktreeHandlers', () => {
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('uses cmd.exe for archive hooks on Windows-like SSH worktree paths', async () => {
const repo = {
id: 'repo-ssh',
path: 'C:\\remote\\repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: null
}
const provider = {
listWorktrees: vi.fn().mockResolvedValue([
{
path: 'C:\\remote\\repo',
head: 'main',
branch: 'main',
isBare: false,
isMainWorktree: true
},
{
path: 'C:\\remote\\feature-wt',
head: 'feature',
branch: 'feature',
isBare: false,
isMainWorktree: false
}
]),
removeWorktree: vi.fn().mockResolvedValue(undefined),
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
execNonInteractive: vi.fn().mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false
})
}
const fsProvider = {
readFile: vi.fn().mockResolvedValue({
content: 'scripts:\n archive: echo archived\n',
isBinary: false
})
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-ssh::C:\\remote\\feature-wt'
})
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\orca.yaml')
expect(provider.execNonInteractive).toHaveBeenCalledWith(
'cmd.exe',
['/d', '/s', '/c', 'echo archived'],
'C:\\remote\\feature-wt',
120_000,
undefined,
expect.objectContaining({
ORCA_ROOT_PATH: 'C:\\remote\\repo',
ORCA_WORKTREE_PATH: 'C:\\remote\\feature-wt'
})
)
})
it('skips the archive hook before removing an SSH worktree when skipArchive is true', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: null
}
const provider = {
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/repo',
head: 'main',
branch: 'main',
isBare: false,
isMainWorktree: true
},
{
path: '/remote/feature-wt',
head: 'feature',
branch: 'feature',
isBare: false,
isMainWorktree: false
}
]),
removeWorktree: vi.fn().mockResolvedValue(undefined),
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
execNonInteractive: vi.fn()
}
const fsProvider = {
readFile: vi.fn().mockResolvedValue({
content: 'scripts:\n archive: echo archived\n',
isBinary: false
})
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-ssh::/remote/feature-wt',
skipArchive: true
})
expect(provider.execNonInteractive).not.toHaveBeenCalled()
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
})
// Regression cover for #19334: a failed archive hook is a blocking precondition, not an advisory.
it('refuses removal and mutates nothing when the local archive hook exits 23', async () => {
mockKnownFeatureWorktree()
@@ -12,8 +12,7 @@ import {
gitExecFileAsyncMock,
getSshGitProviderMock,
getSshFilesystemProviderMock,
killAllProcessesForWorktreeMock,
getSshPtyProviderMock
killAllProcessesForWorktreeMock
} from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { mockKnownFeatureWorktree } from './worktrees-test-fixtures'
@@ -324,7 +323,6 @@ describe('registerWorktreeHandlers', () => {
getSshFilesystemProviderMock.mockReturnValue({
readFile: vi.fn().mockRejectedValue(new Error('missing'))
})
getSshPtyProviderMock.mockReturnValue(undefined)
await expect(
handlers['worktrees:remove'](null, {
+1 -4
View File
@@ -65,8 +65,7 @@ import {
recordRemovalSnapshotPruneMock,
killAllProcessesForWorktreeMock,
clearProviderPtyStateMock,
getLocalPtyProviderMock,
getSshPtyProviderMock
getLocalPtyProviderMock
} from './worktrees-test-module-mocks'
export {
@@ -164,7 +163,6 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
killAllProcessesForWorktreeMock,
clearProviderPtyStateMock,
getLocalPtyProviderMock,
getSshPtyProviderMock,
deleteWorktreeHistoryDirMock,
advertisedUrlWatcherForgetWorktreeMock,
pruneCleanupScanSnapshotMock,
@@ -186,7 +184,6 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
assertWorktreeCleanForRemovalMock.mockResolvedValue(undefined)
findExistingWorktreeSymlinkPathsMock.mockResolvedValue([])
getLocalPtyProviderMock.mockReturnValue({} as never)
getSshPtyProviderMock.mockReturnValue({} as never)
for (const key of Object.keys(handlers)) {
delete handlers[key]
+1 -3
View File
@@ -95,7 +95,6 @@ export const recordRemovalSnapshotPruneMock: ModuleMock = vi.fn()
export const killAllProcessesForWorktreeMock: ModuleMock = vi.fn()
export const clearProviderPtyStateMock: ModuleMock = vi.fn()
export const getLocalPtyProviderMock: ModuleMock = vi.fn()
export const getSshPtyProviderMock: ModuleMock = vi.fn()
// Why: vi.mock factories are hoisted per test file, so each file calls these builders instead of
// closing over module-scope bindings.
@@ -272,6 +271,5 @@ export const worktreeTeardownModuleMock = () => ({
export const ptyModuleMock = () => ({
clearProviderPtyState: clearProviderPtyStateMock,
getLocalPtyProvider: getLocalPtyProviderMock,
getSshPtyProvider: getSshPtyProviderMock
getLocalPtyProvider: getLocalPtyProviderMock
})
@@ -9,7 +9,7 @@ import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { getSshPtyProvider, getLocalPtyProvider, clearProviderPtyState } from '../../pty'
import { getLocalPtyProvider, clearProviderPtyState } from '../../pty'
import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown'
import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache'
import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup'
@@ -90,8 +90,6 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi
args.hostId
)
const ownerHost = parseExecutionHostId(ownerHostId)
const sshPtyProvider =
ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined
const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime'
// External host inventories must never sweep a same-id local workspace.
await killAllProcessesForWorktree(args.worktreeId, {
@@ -101,17 +99,12 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi
...(ownerHost?.kind === 'runtime'
? { resolvedRuntimeEnvironmentId: ownerHost.environmentId }
: {}),
localProvider: sshPtyProvider ?? getLocalPtyProvider(),
localProvider: getLocalPtyProvider(),
onPtyStopped: clearProviderPtyState,
// Forgetting an orphan still purges its workspace metadata, so retire structured chat
// tabs even when no provider child is attached to the workspace.
closeStructuredSessions: true,
...(externalHost
? {
includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider),
includeLocalRegistry: false
}
: {})
...(externalHost ? { includeProviderInventory: false, includeLocalRegistry: false } : {})
}).catch((err) => {
console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err)
})
@@ -1,12 +1,11 @@
import type { Repo } from '../../../../shared/repo-types'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types'
import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup'
import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown'
import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation'
import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup'
import { notifyWorktreesChanged } from '../../worktree-remote'
import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from '../../pty'
import { clearProviderPtyState, getLocalPtyProvider } from '../../pty'
import type { RemoveWorktreeArgs } from '../ipc-context-schemas'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
import { getFolderWorkspaceRootId } from '../folder-workspace-model'
@@ -25,8 +24,6 @@ export async function removeFolderWorkspace(
throw new Error('Cannot delete the project root workspace. Remove the folder project instead.')
}
const ownerHost = parseExecutionHostId(removalHostId)
const sshPtyProvider =
ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined
// Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata.
await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => {
// Folder projects can be SSH-backed, so fence the sweep to the owning host exactly
@@ -41,23 +38,17 @@ export async function removeFolderWorkspace(
...(ownerHost?.kind === 'runtime'
? { resolvedRuntimeEnvironmentId: ownerHost.environmentId }
: {}),
localProvider: sshPtyProvider ?? getLocalPtyProvider(),
localProvider: getLocalPtyProvider(),
onPtyStopped: clearProviderPtyState,
// A structured session is on no PTY surface, so the sweeps above leave it attached to a
// workspace this removal is about to forget. Closed best-effort, exactly like those PTYs.
closeStructuredSessions: true,
...(externalHost
? {
includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider),
includeLocalRegistry: false
}
: {})
...(externalHost ? { includeProviderInventory: false, includeLocalRegistry: false } : {})
}).catch((err) => {
console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err)
})
})
await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => {
await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId)
removeWorktreeMetadataAndTransientState(
store,
args.worktreeId,
@@ -5,9 +5,7 @@ import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree
import type { SshGitProvider } from '../../../providers/ssh-git-provider'
import { previewNestedWorktreeRemoval } from './nested-worktree-removal'
import { assertNestedWorktreeRemovalApproval } from '../../../nested-worktree-removal-plan'
import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup'
import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation'
import { getSshPtyProvider } from '../../pty'
import {
cleanupUnusedWorktreePushTargetRemoteSsh,
notifyWorktreesChanged
@@ -89,7 +87,6 @@ export async function removeRegisteredRemoteWorktree(
removedPushTarget,
store
)
await deleteRemoteWorktreeHistory(getSshPtyProvider(remoteConnectionId), args.worktreeId)
rememberPreservedBranchCleanupTarget(
args.worktreeId,
removalHostId,
@@ -1,6 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../pty', () => ({ getSshPtyProvider: () => undefined }))
vi.mock('../../worktree-remote', () => ({
cleanupUnusedWorktreePushTargetRemote: vi.fn(async () => {}),
cleanupUnusedWorktreePushTargetRemoteSsh: vi.fn(async () => {}),
@@ -6,7 +6,6 @@ import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options'
import type { SshGitProvider } from '../../../providers/ssh-git-provider'
import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch'
import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup'
import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup'
import {
assertWorktreeDoesNotContainRegisteredWorktree,
@@ -28,7 +27,6 @@ import {
cleanupUnusedWorktreePushTargetRemoteSsh,
notifyWorktreesChanged
} from '../../worktree-remote'
import { getSshPtyProvider } from '../../pty'
import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache'
import type { RemoveWorktreeArgs } from '../ipc-context-schemas'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
@@ -106,11 +104,6 @@ export async function removeUnregisteredWorktree(
} finally {
await removalGate.finish(removalCompleted)
}
// Why history first: the worktree is already gone from git and
// disk by here, so a rejecting push-target cleanup must not be
// able to skip history removal and leave the user's commands on
// the remote host.
await deleteRemoteWorktreeHistory(getSshPtyProvider(repo.connectionId), args.worktreeId)
await cleanupUnusedWorktreePushTargetRemoteSsh(
provider!,
repo.path,
@@ -217,11 +210,6 @@ export async function removeUnregisteredWorktree(
}
// Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed.
if (repo.connectionId) {
// Why history first: the worktree is already gone from git and
// disk by here, so a rejecting push-target cleanup must not be
// able to skip history removal and leave the user's commands on
// the remote host.
await deleteRemoteWorktreeHistory(getSshPtyProvider(repo.connectionId), args.worktreeId)
await cleanupUnusedWorktreePushTargetRemoteSsh(
provider!,
repo.path,
@@ -1,5 +1,5 @@
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import { getSshPtyProvider, getLocalPtyProvider, clearProviderPtyState } from '../../pty'
import { getLocalPtyProvider, clearProviderPtyState } from '../../pty'
import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown'
import type { Store } from '../../../persistence/loading-store/store'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
@@ -22,8 +22,8 @@ export async function stopPtysForDestructiveWorktreeRemoval(
options: { connectionId?: string; allowUnverifiedStop?: boolean } = {}
): Promise<void> {
const { connectionId, allowUnverifiedStop } = options
const provider = connectionId ? getSshPtyProvider(connectionId) : getLocalPtyProvider()
if (!provider) {
// Why: this process holds no SSH PTY provider, so an SSH workspace's terminals cannot be stopped here.
if (connectionId) {
throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`)
}
const teardownResult = await killAllProcessesForWorktree(worktreeId, {
@@ -32,14 +32,12 @@ export async function stopPtysForDestructiveWorktreeRemoval(
// workspace's terminals on another connection — and the selector lookup this replaces
// throws `selector_ambiguous` the moment two hosts own the id.
resolvedWorktreeId: worktreeId,
...(connectionId ? { resolvedConnectionId: connectionId } : {}),
localProvider: provider,
localProvider: getLocalPtyProvider(),
onPtyStopped: clearProviderPtyState,
requirePhysicalStop: true,
// Why (#11960): set only by an explicit Force Delete, never by the ordinary
// confirmation — otherwise the gate would be off on the primary delete path.
...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}),
...(connectionId ? { includeLocalRegistry: false } : {})
...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {})
})
// Structured sessions are counted here too: closing a user's chat is now an ordinary outcome
// of this verb, and a removal that closed one but no PTY would otherwise log nothing at all.
+1 -5
View File
@@ -159,8 +159,7 @@ async function startOrcadRuntime(
): Promise<Pick<OrcadHandle, 'readiness'>> {
const { OrcaRuntimeService } = await import('../runtime/orca-runtime')
const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc')
const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } =
await import('../ipc/pty')
const { registerHeadlessPtyRuntime, getLocalPtyProvider } = await import('../ipc/pty')
const { getAppEnvironment } = await import('../../shared/app-environment')
const { installOrcadObservability } = await import('./orcad-observability')
closeOrcadObservability = installOrcadObservability()
@@ -245,9 +244,6 @@ async function startOrcadRuntime(
// Why lazy: a daemon swap replaces the provider after construction, so an eager
// reference would freeze the pre-daemon one.
getLocalProvider: () => getLocalPtyProvider(),
// 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 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
+1 -2
View File
@@ -82,8 +82,7 @@ vi.mock('../runtime/agent-state-rules/agent-state-rules-live-update', () => ({
vi.mock('../daemon/daemon-provider-state', () => ({ daemonOwnsFreshPersistentPtys: () => false }))
vi.mock('../ipc/pty', () => ({
registerHeadlessPtyRuntime: async () => {},
getLocalPtyProvider: () => null,
getSshPtyProvider: () => null
getLocalPtyProvider: () => null
}))
vi.mock('./orcad-profile-state-startup', () => ({
createOrcadProfileStateStartup: async () => {
+1 -123
View File
@@ -1,5 +1,4 @@
import { openCodeHookServiceModuleMock } from '../ipc/pty-ipc-mock-registry'
import { settledWriteStub } from './settled-pty-write-stub'
import { describe, expect, it, vi } from 'vitest'
import { setPtyHostBindings } from '../ipc/pty-host-bindings'
@@ -55,15 +54,7 @@ vi.mock('../pi/titlebar-extension-service', () => ({
piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() }
}))
import {
deletePtyOwnership,
registerPtyHandlers,
registerSshPtyProvider,
setPtyOwnership,
unregisterSshPtyProvider
} from '../ipc/pty'
import type { IPtyProvider } from './types'
import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../pty/legacy-terminal-shim-dir'
import { registerPtyHandlers } from '../ipc/pty'
describe('PTY provider dispatch', () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>()
@@ -71,7 +62,6 @@ describe('PTY provider dispatch', () => {
isDestroyed: () => false,
webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() }
}
const mainWindowIpcEvent = { sender: mainWindow.webContents }
function setup(): void {
handlers.clear()
@@ -96,33 +86,6 @@ describe('PTY provider dispatch', () => {
registerPtyHandlers(mainWindow as never)
}
function createMockProvider(id: string): IPtyProvider {
return {
spawn: vi.fn().mockResolvedValue({ id }),
attach: vi.fn(),
write: vi.fn(),
writeWithSettlement: vi.fn(settledWriteStub()),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
resetInputModes: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
listProcesses: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn(),
onData: vi.fn().mockReturnValue(() => {}),
onReplay: vi.fn().mockReturnValue(() => {}),
onExit: vi.fn().mockReturnValue(() => {})
}
}
it('routes to local provider when connectionId is null', async () => {
setup()
const result = (await handlers.get('pty:spawn')!(null, {
@@ -142,49 +105,6 @@ describe('PTY provider dispatch', () => {
expect(result.id).toBeTruthy()
})
it('routes to SSH provider when connectionId is set', async () => {
setup()
const mockSshProvider = createMockProvider('ssh-pty-1')
registerSshPtyProvider('conn-123', mockSshProvider)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'conn-123'
})) as { id: string }
expect(result.id).toBe('ssh-pty-1')
// Why: the relay host can be launched from a Claude or structured session too, so the
// stamps are stripped on the SSH path as well; a remote pane never names a local session. Compared as a set — envToDelete is consumed by
// membership only, so a reordering of the merge sources must not fail this.
const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0]
expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual(
[
...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS,
'CLAUDE_CODE_CHILD_SESSION',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_BRIDGE_SESSION_ID',
'ORCA_OPENCODE_PLUGIN_API',
'ORCA_OPENCODE_STARTUP_PROMPT_BODY',
'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT',
'ORCA_OPENCODE_STARTUP_PROMPT_NONCE',
'ORCA_OPENCODE_STARTUP_PROMPT_SHA256',
'ORCA_OPENCODE_STARTUP_PROMPT_SHELL',
'ORCA_PI_STATUS_OWNED',
'ORCA_PRIME_AGENT_STATUS_OWNED',
'ORCA_PI_TITLE_MARKER_OWNED',
'ORCA_AGENT_SESSION_ID',
'ORCA_STRUCTURED_SESSION'
].sort()
)
expect(mockSshProvider.spawn).toHaveBeenCalledWith(
expect.objectContaining({ cols: 80, rows: 24, cwd: undefined, env: undefined })
)
unregisterSshPtyProvider('conn-123')
})
it('throws for unknown connectionId', async () => {
setup()
await expect(
@@ -195,46 +115,4 @@ describe('PTY provider dispatch', () => {
})
).rejects.toThrow(/^No PTY provider for connection "unknown-conn"/)
})
it('unregisterSshPtyProvider removes the provider', async () => {
setup()
const mockProvider = createMockProvider('ssh-pty-2')
registerSshPtyProvider('conn-456', mockProvider)
unregisterSshPtyProvider('conn-456')
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'conn-456'
})
).rejects.toThrow(/^No PTY provider for connection "conn-456"/)
})
it('keeps same relay PTY ids distinct across SSH targets', () => {
setup()
const providerA = createMockProvider('ssh:conn-a@@pty-1')
const providerB = createMockProvider('ssh:conn-b@@pty-1')
registerSshPtyProvider('conn-a', providerA)
registerSshPtyProvider('conn-b', providerB)
setPtyOwnership('ssh:conn-a@@pty-1', 'ssh:conn-a')
setPtyOwnership('ssh:conn-b@@pty-1', 'ssh:conn-b')
try {
const write = handlers.get('pty:write') as (event: unknown, args: unknown) => void
write(mainWindowIpcEvent, { id: 'ssh:conn-a@@pty-1', data: 'a' })
write(mainWindowIpcEvent, { id: 'ssh:conn-b@@pty-1', data: 'b' })
expect(providerA.write).toHaveBeenCalledWith('ssh:conn-a@@pty-1', 'a')
expect(providerB.write).toHaveBeenCalledWith('ssh:conn-b@@pty-1', 'b')
expect(providerA.write).not.toHaveBeenCalledWith('ssh:conn-b@@pty-1', expect.anything())
expect(providerB.write).not.toHaveBeenCalledWith('ssh:conn-a@@pty-1', expect.anything())
} finally {
deletePtyOwnership('ssh:conn-a@@pty-1')
deletePtyOwnership('ssh:conn-b@@pty-1')
unregisterSshPtyProvider('conn-a')
unregisterSshPtyProvider('conn-b')
}
})
})
-3
View File
@@ -2,7 +2,6 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges
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'
import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version'
@@ -15,8 +14,6 @@ export type PtySpawnResult = {
id: string
/** Opaque provider-owned identity for this process behind a reusable PTY id. */
incarnationId?: PtyIncarnationId
/** Relay source identity installed before adjacent source frames are decoded. */
sourceActivation?: PtySourceReceivingActivation
/** The provider observed this exact spawn exit before returning its spawn result. */
exitedBeforeSpawnReply?: true
/** Whether the execution host armed the shell-ready marker for a renderer-delivered startup
@@ -1,82 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { proveSshAgentSessionClaimCapability } from './ssh-agent-session-claim-validation'
import { sshSupportsAgentSessionCreateOperations } from './ssh-agent-session-create-operation'
import { waitForSshCapabilityProbe } from './ssh-capability-probe-waiter'
export class SshAgentSessionCapabilities {
private claimProbe: Promise<void> | null = null
private claimSupported = false
private createOperationProbe: Promise<boolean> | null = null
private foregroundEvidenceProbe: Promise<boolean> | null = null
constructor(private readonly mux: SshChannelMultiplexer) {}
async supportsClaims(options: { signal?: AbortSignal } = {}): Promise<boolean> {
const probe = this.claimProbe ?? proveSshAgentSessionClaimCapability(this.mux)
this.claimProbe = probe
try {
await waitForSshCapabilityProbe(probe, options.signal)
this.claimSupported = true
return true
} catch {
if (!options.signal?.aborted && this.claimProbe === probe) {
// Why: negative physical probes must follow a relay upgraded on this connection.
this.claimProbe = null
this.claimSupported = false
}
return false
}
}
providesOwnerListings(): boolean {
return this.claimSupported
}
async supportsCreateOperations(options: { signal?: AbortSignal } = {}): Promise<boolean> {
const probe = this.createOperationProbe ?? sshSupportsAgentSessionCreateOperations(this.mux)
this.createOperationProbe = probe
let supported: boolean
try {
supported = await waitForSshCapabilityProbe(probe, options.signal)
} catch {
// Why: one canceled waiter must not cancel or evict the shared physical probe used by peers.
return false
}
if (!supported && this.createOperationProbe === probe) {
// Why: negative capability results must follow a relay upgraded on the same connection.
this.createOperationProbe = null
}
return supported
}
/** Whether this relay understands the opt-in no-evidence inventory projection. */
async supportsForegroundProcessEvidence(
options: { signal?: AbortSignal } = {}
): Promise<boolean> {
const probe =
this.foregroundEvidenceProbe ??
this.mux
.request('pty.getCapabilities', undefined, {
signal: options.signal,
timeoutMs: 5_000
})
.then((value) => {
const capabilities = value as { foregroundProcessEvidenceVersion?: unknown }
return capabilities.foregroundProcessEvidenceVersion === 1
})
.catch(() => false)
this.foregroundEvidenceProbe = probe
try {
const supported = await waitForSshCapabilityProbe(probe, options.signal)
if (!supported && this.foregroundEvidenceProbe === probe) {
this.foregroundEvidenceProbe = null
}
return supported
} catch {
if (!options.signal?.aborted && this.foregroundEvidenceProbe === probe) {
this.foregroundEvidenceProbe = null
}
return false
}
}
}
@@ -1,90 +0,0 @@
import {
AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION,
isAgentSessionClaimedSpawnResult,
type AgentSessionExecutionClaim,
type AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import type { PtySpawnResult } from './pty-spawn-result'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-create-operation'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
export type ClaimedSshSpawnValidation =
| { valid: true }
| { valid: false; cleanup: 'created' | 'none'; error: string }
export async function proveSshAgentSessionClaimCapability(
mux: SshChannelMultiplexer,
options: { signal?: AbortSignal } = {}
): Promise<void> {
try {
const result = (await mux.request('pty.getCapabilities', undefined, {
signal: options.signal,
timeoutMs: SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS
})) as {
agentSessionClaimVersion?: unknown
}
if (result.agentSessionClaimVersion !== AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION) {
throw new Error('unsupported')
}
} catch {
throw new Error('agent_session_claim_unavailable')
}
}
function claimsEqual(
actual: AgentSessionExecutionClaim,
expected: AgentSessionExecutionClaim
): boolean {
return (
actual.digestVersion === expected.digestVersion &&
actual.keyId === expected.keyId &&
actual.identityDigest === expected.identityDigest &&
actual.worktreeScopeDigest === expected.worktreeScopeDigest &&
actual.agent === expected.agent
)
}
function surfacesEqual(
actual: AgentSessionSurfaceBinding,
expected: AgentSessionSurfaceBinding
): boolean {
return (
actual.worktreeId === expected.worktreeId &&
actual.tabId === expected.tabId &&
actual.leafId === expected.leafId &&
actual.terminalHandle === expected.terminalHandle
)
}
export function validateClaimedSshSpawn(
result: PtySpawnResult,
expected: {
claim: AgentSessionExecutionClaim
surface: AgentSessionSurfaceBinding
}
): ClaimedSshSpawnValidation {
const claimed = result.agentSessionEnsure
if (!isAgentSessionClaimedSpawnResult(claimed)) {
// Why: without a disposition we cannot prove the returned PTY was newly
// created, so killing it could terminate a canonical adopted owner.
return { valid: false, cleanup: 'none', error: 'execution_owner_unavailable' }
}
const cleanup = claimed.disposition === 'created' ? 'created' : 'none'
if (claimed.owner.ptyId !== result.id) {
return { valid: false, cleanup, error: 'agent_session_ownership_unknown' }
}
if (!claimsEqual(claimed.owner.claim, expected.claim)) {
return { valid: false, cleanup, error: 'agent_session_ownership_unknown' }
}
if (
claimed.disposition === 'created' &&
!surfacesEqual(claimed.owner.surface, expected.surface)
) {
return { valid: false, cleanup, error: 'agent_session_ownership_unknown' }
}
if (!isPtyIncarnationId(result.incarnationId)) {
return { valid: false, cleanup, error: 'agent_session_ownership_unknown' }
}
return { valid: true }
}
@@ -1,184 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtySpawnResult } from './pty-spawn-result'
import type { PtySpawnOptions } from './types'
import type { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
import type { SshPtyReceivingActivationLease } from './ssh-pty-notification-routing'
import {
parsePtySourceReceivingActivation,
type PtySourceReceivingActivation
} from '../../shared/pty-source-receiving-activation'
import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation'
export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000
export function assertSshAgentSessionCreateResult(
result: unknown
): asserts result is PtySpawnResult {
const candidate = result as Partial<PtySpawnResult> | null
if (
typeof candidate?.id === 'string' &&
candidate.id.length > 0 &&
candidate.id.length <= 512 &&
isPtyIncarnationId(candidate.incarnationId)
) {
return
}
// Why: a malformed success arrived after dispatch, so retain the replay fence instead of
// falling back or issuing a fresh operation that could duplicate a live PTY.
throw Object.assign(new Error('execution_owner_unavailable'), {
agentSessionOperationOutcome: 'unknown' as const
})
}
export async function sshSupportsAgentSessionCreateOperations(
mux: SshChannelMultiplexer,
options: { signal?: AbortSignal } = {}
): Promise<boolean> {
try {
const result = (await mux.request('pty.getCapabilities', undefined, {
signal: options.signal,
timeoutMs: SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS
})) as {
agentSessionCreateOperationVersion?: unknown
}
return (
result.agentSessionCreateOperationVersion === AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
)
} catch {
// Why: capability probing does not spawn, so an old relay can safely keep legacy behavior.
return false
}
}
export async function requestSshAgentSessionCreate(args: {
mux: SshChannelMultiplexer
params: Record<string, unknown>
operationId?: string
signal?: AbortSignal
beforeResolve?: (result: unknown) => void
}): Promise<unknown> {
try {
const options =
args.signal || args.beforeResolve
? { signal: args.signal, beforeResolve: args.beforeResolve }
: undefined
return await args.mux.request('pty.spawn', args.params, options)
} catch (error) {
if (!args.operationId) {
throw error
}
const spawnError = error instanceof Error ? error : new Error(String(error))
// Why: after request dispatch, either an old relay or a capable replay ledger may own a PTY.
throw Object.assign(spawnError, { agentSessionOperationOutcome: 'unknown' as const })
}
}
export async function spawnFreshSshPty(args: {
mux: SshChannelMultiplexer
options: PtySpawnOptions
params: Record<string, unknown>
exitRaceTracker: SshPtySpawnExitRaceTracker
installSourceActivation: (
relayPtyId: string,
activation: PtySourceReceivingActivation
) => SshPtyReceivingActivationLease
rememberPtyIncarnation: (relayPtyId: string, incarnationId: unknown) => void
acceptLivePty: (appPtyId: string) => void
toAppPtyId: (relayPtyId: string) => string
}): Promise<PtySpawnResult> {
const operation = args.exitRaceTracker.begin()
let sourceActivationLease: SshPtyReceivingActivationLease | undefined
try {
const result = await requestSshAgentSessionCreate({
mux: args.mux,
operationId: args.options.agentSessionCreateOperationId,
signal: args.options.signal,
params: args.params,
beforeResolve: (value) => {
sourceActivationLease = installSpawnSourceActivation(value, args.installSourceActivation)
}
})
if (args.options.agentSessionCreateOperationId) {
assertSshAgentSessionCreateResult(result)
}
const spawnResult = parseSshPtySpawnResult(result)
if (args.exitRaceTracker.didMatchingExitArrive(operation, spawnResult)) {
throw Object.assign(new Error('agent_session_exited_during_start'), {
agentSessionOperationOutcome: 'unknown' as const
})
}
const claimed = spawnResult.agentSessionEnsure
if (args.options.agentSessionEnsure) {
const validation = validateClaimedSshSpawn(spawnResult, args.options.agentSessionEnsure)
if (!validation.valid) {
if (validation.cleanup === 'created' && typeof spawnResult.id === 'string') {
try {
await args.mux.request('pty.shutdown', { id: spawnResult.id, immediate: true })
} catch {
throw new Error('execution_owner_unavailable')
}
}
throw new Error(validation.error)
}
}
const id = args.toAppPtyId(spawnResult.id)
args.rememberPtyIncarnation(spawnResult.id, spawnResult.incarnationId)
args.acceptLivePty(id)
const mappedResult = {
...spawnResult,
id,
...(claimed
? {
agentSessionEnsure: {
...claimed,
owner: { ...claimed.owner, ptyId: args.toAppPtyId(claimed.owner.ptyId) }
}
}
: {})
}
sourceActivationLease?.commit()
return mappedResult
} catch (error) {
if (sourceActivationLease && !(await sourceActivationLease.rollback())) {
throw new Error('execution_owner_unavailable')
}
throw error
} finally {
args.exitRaceTracker.finish(operation)
}
}
function installSpawnSourceActivation(
value: unknown,
install: (
relayPtyId: string,
activation: PtySourceReceivingActivation
) => SshPtyReceivingActivationLease
): SshPtyReceivingActivationLease | undefined {
const result = parseSshPtySpawnResult(value)
const activation = result.sourceActivation
if (!activation) {
return undefined
}
return install(result.id, activation)
}
function parseSshPtySpawnResult(value: unknown): PtySpawnResult {
const result =
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as PtySpawnResult)
: ({} as PtySpawnResult)
const activation = parsePtySourceReceivingActivation(result.sourceActivation)
if (
activation &&
(typeof result.id !== 'string' ||
result.id.length === 0 ||
!isPtyIncarnationId(result.incarnationId) ||
activation.ptyIncarnation !== result.incarnationId)
) {
throw new Error('Invalid SSH PTY source activation identity')
}
return activation ? { ...result, sourceActivation: activation } : result
}
@@ -1,28 +0,0 @@
import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtyProcessInfo } from './types'
export function mapSshPtyProcessList(
sessions: PtyProcessInfo[],
toAppPtyId: (id: string) => string
): PtyProcessInfo[] {
return sessions.map((session) => {
if (session.agentSessionOwners?.length && !isPtyIncarnationId(session.incarnationId)) {
throw new Error('agent_session_ownership_unknown')
}
return {
...session,
id: toAppPtyId(session.id),
...(session.agentSessionOwners
? {
agentSessionOwners: session.agentSessionOwners.map((owner) => {
if (!isAgentSessionOwnerBinding(owner) || owner.ptyId !== session.id) {
throw new Error('agent_session_ownership_unknown')
}
return { ...owner, ptyId: toAppPtyId(owner.ptyId) }
})
}
: {})
}
})
}
@@ -1,24 +0,0 @@
export function waitForSshCapabilityProbe<T>(probe: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return probe
}
if (signal.aborted) {
return Promise.reject(new Error('client_disconnected'))
}
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
reject(new Error('client_disconnected'))
}
signal.addEventListener('abort', onAbort, { once: true })
void probe.then(
(result) => {
signal.removeEventListener('abort', onAbort)
resolve(result)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(error)
}
)
})
}
@@ -1,49 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
import { toRelaySshPtyId } from './ssh-pty-id'
type AppliedPtySize = { cols: number; rows: number }
// Why: wake repair can safely re-forward on failure, so never inherit the generic 30-second RPC timeout.
const SSH_PTY_APPLIED_SIZE_TIMEOUT_MS = 1_000
export function createSshPtyAppliedSizeReader(
mux: SshChannelMultiplexer,
connectionId: string
): (id: string) => Promise<AppliedPtySize | null> {
let supported: boolean | null = null
return async (id) => {
if (supported === false) {
return null
}
try {
const result = (await mux.request(
'pty.getSize',
{
id: toRelaySshPtyId(connectionId, id)
},
{ timeoutMs: SSH_PTY_APPLIED_SIZE_TIMEOUT_MS }
)) as {
cols?: unknown
rows?: unknown
} | null
supported = true
if (
!result ||
!Number.isInteger(result.cols) ||
!Number.isInteger(result.rows) ||
Number(result.cols) <= 0 ||
Number(result.rows) <= 0
) {
return null
}
return { cols: Number(result.cols), rows: Number(result.rows) }
} catch (error) {
if ((error as { code?: unknown })?.code === JsonRpcErrorCode.MethodNotFound) {
// Why: old relays lack pty.getSize; remember that per SSH provider so
// each wake re-forwards once without repeatedly probing the same host.
supported = false
}
return null
}
}
}
-8
View File
@@ -2,14 +2,6 @@ import { errorMessage } from '../../shared/error-message'
export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
export const SSH_PTY_IDENTITY_MISMATCH_ERROR = 'SSH_PTY_IDENTITY_MISMATCH'
/**
* The relay accepted the attach for a PTY it had just proven alive and only retired the stale
* output delivery. Deliberately not `SSH_SESSION_EXPIRED`: every consumer of that token retires the
* pane binding and cold-restores the agent, which duplicates a running agent onto one transcript
* (docs/reference/ssh-execution-boundary.md — respawning needs host evidence of absence, and this
* reply is host evidence of the opposite).
*/
export const SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR = 'SSH_PTY_SOURCE_RESTORE_REQUIRED'
export function isSshPtyNotFoundError(error: unknown): boolean {
const message = errorMessage(error)
@@ -1,68 +0,0 @@
/**
* Ratchet (#18419): `pty.inspectProcess` must NOT be in-flight coalesced the way the sibling git
* reads in `SshGitReadProvider` are. The host mints one `observationEpoch` per request and the
* pane foreground reader commits that epoch per read, so a shared reply reads as a stale replay to
* the second reader to settle — see the companion renderer proof in
* `src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts`.
* These are request counters, not timings.
*/
import { describe, expect, it, vi } from 'vitest'
import { createSshPtyProviderRpcOperations } from './ssh-pty-provider-rpc-operations'
const RELAY_PTY_ID = 'pty-1'
const APP_PTY_ID = `ssh:conn-1@@${RELAY_PTY_ID}`
const INCARNATION_ID = 'inc-1'
/** Answers `pty.inspectProcess` with a fresh host observation per request, held open on demand. */
function createInspectingOperations(): {
operations: ReturnType<typeof createSshPtyProviderRpcOperations>
request: ReturnType<typeof vi.fn>
resolvers: ((value: unknown) => void)[]
} {
const resolvers: ((value: unknown) => void)[] = []
const request = vi.fn(() => new Promise((resolve) => resolvers.push(resolve)))
return {
operations: createSshPtyProviderRpcOperations({
mux: { request } as never,
toRelayPtyId: () => RELAY_PTY_ID
}),
request,
resolvers
}
}
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
describe('SSH pty.inspectProcess observation identity', () => {
it('gives each overlapping probe of one pane+incarnation its own host observation', async () => {
const { operations, request, resolvers } = createInspectingOperations()
const first = operations.inspectProcess(APP_PTY_ID, { expectedIncarnationId: INCARNATION_ID })
const second = operations.inspectProcess(APP_PTY_ID, { expectedIncarnationId: INCARNATION_ID })
await flush()
expect(request).toHaveBeenCalledTimes(2)
resolvers[0]({ foregroundProcess: 'claude', observationEpoch: 1 })
resolvers[1]({ foregroundProcess: 'claude', observationEpoch: 2 })
// Each read settles on the observation minted for it, never a neighbour's.
expect(await first).toMatchObject({ observationEpoch: 1 })
expect(await second).toMatchObject({ observationEpoch: 2 })
})
it('does not share a failed probe with an overlapping one', async () => {
const { operations, request, resolvers } = createInspectingOperations()
const failing = operations.inspectProcess(APP_PTY_ID, { expectedIncarnationId: INCARNATION_ID })
const overlapping = operations.inspectProcess(APP_PTY_ID, {
expectedIncarnationId: INCARNATION_ID
})
await flush()
expect(request).toHaveBeenCalledTimes(2)
resolvers[0](Promise.reject(new Error('relay dropped the probe')))
resolvers[1]({ foregroundProcess: 'claude', observationEpoch: 1 })
await expect(failing).rejects.toThrow('relay dropped the probe')
expect(await overlapping).toMatchObject({ observationEpoch: 1 })
})
})
@@ -1,104 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR,
SSH_SESSION_EXPIRED_ERROR,
isSshPtyAbsentFromRelayError
} from './ssh-pty-errors'
import { SshPtyProvider } from './ssh-pty-provider'
const RESTORE_REQUIRED = {
incarnationId: 'incarnation-1',
sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' }
}
function providerWithAttachReplies(replies: unknown[]): {
provider: SshPtyProvider
request: ReturnType<typeof vi.fn>
} {
const request = vi.fn()
for (const reply of replies) {
request.mockResolvedValueOnce(reply)
}
const mux = {
request,
notify: vi.fn(),
onNotification: vi.fn().mockReturnValue(vi.fn())
}
return { provider: new SshPtyProvider('conn-1', mux as never), request }
}
describe('a live PTY whose source delivery needs restoring', () => {
// The relay only reaches a restoreRequired reply after finding the managed PTY and confirming its
// process is alive, so the reply is evidence of liveness. `SSH_SESSION_EXPIRED` is the token every
// caller uses to retire the pane binding and cold-restore the agent — emitting it here put a
// second `claude --resume` onto the transcript of a still-running one (#11006), and leaked the
// abandoned remote PTY on every reconnect until the host refused to fork (#9034).
it('does not claim the session expired after the retry still needs a restore', async () => {
const { provider, request } = providerWithAttachReplies([RESTORE_REQUIRED, RESTORE_REQUIRED])
const rejection = await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-1' }).then(
() => undefined,
(error: unknown) => error
)
expect((rejection as Error).message).not.toContain(SSH_SESSION_EXPIRED_ERROR)
expect((rejection as Error).message).toContain(SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR)
expect(isSshPtyAbsentFromRelayError(rejection)).toBe(false)
expect(request).toHaveBeenCalledTimes(2)
})
it('reattaches the live PTY when the retry opens a fresh delivery', async () => {
const { provider, request } = providerWithAttachReplies([
RESTORE_REQUIRED,
{ incarnationId: 'incarnation-1', replay: 'restored scrollback' }
])
const result = await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-1' })
expect(result).toMatchObject({
id: 'ssh:conn-1@@pty-1',
isReattach: true,
replay: 'restored scrollback'
})
expect(request).toHaveBeenCalledTimes(2)
})
it('stops retrying rather than stacking a delivery on an unconfirmed cancellation', async () => {
const request = vi.fn().mockResolvedValue({
...RESTORE_REQUIRED,
sourceActivation: {
status: 'pending',
clientGeneration: 1,
ownerGeneration: 1,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
checkpointSourceEndSu: 0,
recoveryEndSu: 0
}
})
const rollback = vi.fn().mockResolvedValue(false)
const mux = {
request,
notify: vi.fn(),
onNotification: vi.fn().mockReturnValue(vi.fn())
}
const provider = new SshPtyProvider('conn-1', mux as never)
const outputState = (provider as unknown as { outputState: Record<string, unknown> })
.outputState
outputState.installReceivingActivation = () => ({
commit: vi.fn(),
rollback,
transferToRecovery: vi.fn()
})
const rejection = await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-1' }).then(
() => undefined,
(error: unknown) => error
)
expect((rejection as Error).message).toContain(SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR)
expect((rejection as Error).message).not.toContain(SSH_SESSION_EXPIRED_ERROR)
expect(request).toHaveBeenCalledTimes(1)
expect(rollback).toHaveBeenCalledTimes(1)
})
})
@@ -1,326 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createSubscription, sourceActivation } from './ssh-pty-notification-routing-test-fixture'
describe('subscribeSshPtyNotifications', () => {
it('accepts non-empty recovery from the activation checkpoint', () => {
const { handler, dataListeners, installReceivingActivation } = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
const lease = installReceivingActivation(
'pty-1',
sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 })
)
handler('pty.data', {
id: 'pty-1',
data: 'next',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 8,
sourceLengthSu: 4
})
expect(onData).not.toHaveBeenCalled()
lease.commit()
expect(onData).toHaveBeenCalledWith(
expect.objectContaining({
data: 'next',
source: expect.objectContaining({ sourceStartSu: 4, sourceEndSu: 8 })
})
)
})
it('routes held and later recovery frames only to the private sink until commit', () => {
const { handler, dataListeners, livePtyIds, installReceivingActivation } = createSubscription()
const onData = vi.fn()
const onRecoveryData = vi.fn()
dataListeners.add(onData)
const lease = installReceivingActivation(
'pty-1',
sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 12 })
)
const publishSource = (data: string, sourceEndSu: number): void => {
handler('pty.data', {
id: 'pty-1',
data,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu,
sourceLengthSu: 4
})
}
publishSource('held', 8)
const recoveryLease = lease.transferToRecovery(onRecoveryData)
publishSource('next', 12)
expect(onRecoveryData.mock.calls.map(([payload]) => payload.data)).toEqual(['held', 'next'])
expect(onData).not.toHaveBeenCalled()
expect(livePtyIds).not.toContain('ssh:conn@@pty-1')
recoveryLease.commit()
expect(onData).not.toHaveBeenCalled()
publishSource('live', 16)
expect(onRecoveryData).toHaveBeenCalledTimes(2)
expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'live' }))
expect(livePtyIds).toContain('ssh:conn@@pty-1')
})
it('retires an exited private recovery when its activation commits', () => {
const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } =
createSubscription()
const onData = vi.fn()
const onRecoveryData = vi.fn()
dataListeners.add(onData)
const lease = installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 4 }))
handler('pty.data', {
id: 'pty-1',
data: 'held',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 4,
sourceLengthSu: 4
})
const recoveryLease = lease.transferToRecovery(onRecoveryData)
handler('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' })
recoveryLease.commit()
handler('pty.data', {
id: 'pty-1',
data: 'late',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 8,
sourceLengthSu: 4
})
expect(onRecoveryData).toHaveBeenCalledOnce()
expect(onData).not.toHaveBeenCalled()
expect(livePtyIds).not.toContain('ssh:conn@@pty-1')
expect(mux.request).toHaveBeenCalledWith(
'pty.cancelDelivery',
expect.objectContaining({ id: 'pty-1', deliveryToken: 'token-1' })
)
})
it('retires private recovery locally and restores the exact predecessor', () => {
const { handler, mux, dataListeners, installReceivingActivation } = createSubscription()
const onData = vi.fn()
const onRecoveryData = vi.fn()
dataListeners.add(onData)
installReceivingActivation(
'pty-1',
sourceActivation({ deliveryToken: 'token-old', recoveryEndSu: 3 })
).commit()
handler('pty.data', {
id: 'pty-1',
data: 'pre',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-old',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 3,
sourceLengthSu: 3
})
const replacement = installReceivingActivation(
'pty-1',
sourceActivation({
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'token-new',
checkpointSourceEndSu: 3,
recoveryEndSu: 6
})
)
handler('pty.data', {
id: 'pty-1',
data: 'new',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-new',
clientGeneration: 3,
ownerGeneration: 4,
sourceEndSu: 6,
sourceLengthSu: 3
})
replacement.transferToRecovery(onRecoveryData).retire()
handler('pty.data', {
id: 'pty-1',
data: 'old',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-old',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 6,
sourceLengthSu: 3
})
expect(onRecoveryData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' }))
expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old'])
expect(mux.request).not.toHaveBeenCalled()
})
it('rejects a stale activation without disturbing current continuity', () => {
const { handler, dataListeners, installReceivingActivation } = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit()
expect(() =>
installReceivingActivation(
'pty-1',
sourceActivation({
clientGeneration: 1,
ownerGeneration: 4,
deliveryToken: 'token-stale',
checkpointSourceEndSu: 3,
recoveryEndSu: 3
})
)
).toThrow('ssh_source_receiving_activation_stale')
handler('pty.data', {
id: 'pty-1',
data: 'one',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 3,
sourceLengthSu: 3
})
expect(onData).toHaveBeenCalledOnce()
})
it('drops provisional frames and settles cancellation before rollback completes', async () => {
const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } =
createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
const lease = installReceivingActivation(
'pty-1',
sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 })
)
handler('pty.data', {
id: 'pty-1',
data: 'next',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 8,
sourceLengthSu: 4
})
await expect(lease.rollback()).resolves.toBe(true)
expect(onData).not.toHaveBeenCalled()
expect(livePtyIds).not.toContain('ssh:conn@@pty-1')
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1'
})
})
it('restores the exact prior cursor when a replacement rolls back after frames', async () => {
const { handler, dataListeners, installReceivingActivation } = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
installReceivingActivation('pty-1', sourceActivation({ deliveryToken: 'token-old' })).commit()
handler('pty.data', {
id: 'pty-1',
data: 'pre',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-old',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 3,
sourceLengthSu: 3
})
const replacement = installReceivingActivation(
'pty-1',
sourceActivation({
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'token-new',
checkpointSourceEndSu: 3,
recoveryEndSu: 3
})
)
handler('pty.data', {
id: 'pty-1',
data: 'new',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-new',
clientGeneration: 3,
ownerGeneration: 4,
sourceEndSu: 6,
sourceLengthSu: 3
})
await replacement.rollback()
handler('pty.data', {
id: 'pty-1',
data: 'old',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-old',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 6,
sourceLengthSu: 3
})
expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old'])
})
it('does not let an older lease rollback replace a newer activation', async () => {
const { handler, mux, dataListeners, installReceivingActivation } = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
const older = installReceivingActivation('pty-1', sourceActivation())
const newer = installReceivingActivation(
'pty-1',
sourceActivation({
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'token-new'
})
)
await older.rollback()
handler('pty.data', {
id: 'pty-1',
data: 'new',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-new',
clientGeneration: 3,
ownerGeneration: 4,
sourceEndSu: 3,
sourceLengthSu: 3
})
newer.commit()
expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' }))
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1'
})
expect(mux.request).not.toHaveBeenCalledWith(
'pty.cancelDelivery',
expect.objectContaining({ deliveryToken: 'token-new' })
)
})
})
@@ -1,183 +0,0 @@
import { expect, it, vi } from 'vitest'
import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing'
it('routes malformed and unadmitted source frames only to rejection diagnostics', async () => {
const mux = {
onNotification: vi.fn(),
request: vi.fn(async (_method: string, params: Record<string, unknown>) => {
if (params.deliveryToken !== 'token-1') {
throw new Error('Unknown or stale PTY source delivery cancellation')
}
return { canceled: true, sentEndSu: 0, creditedEndSu: 0 }
})
}
const dataListeners = new Set()
const rejectedDataListeners = new Set()
const subscription = subscribeSshPtyNotifications({
mux: mux as never,
toAppPtyId: (id) => `ssh:conn@@${id}`,
dataListeners: dataListeners as never,
rejectedDataListeners: rejectedDataListeners as never,
replayListeners: new Set() as never,
exitListeners: new Set() as never,
livePtyIds: new Set(),
recordExit: vi.fn(),
providerGeneration: 7,
resolvePtyIncarnation: (id) => `incarnation:${id}`,
peekPtyIncarnation: () => undefined
})
const handler = mux.onNotification.mock.calls[0]?.[0] as (
method: string,
params: Record<string, unknown>
) => void
const onData = vi.fn()
const onRejectedData = vi.fn()
dataListeners.add(onData)
rejectedDataListeners.add(onRejectedData)
subscription
.installReceivingActivation('pty-1', {
status: 'pending',
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
checkpointSourceEndSu: 0,
recoveryEndSu: 4
})
.commit()
handler('pty.data', {
id: 'pty-1',
data: 'bad',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
sourceEndSu: 4,
sourceLengthSu: 4
})
handler('pty.data', {
id: 'pty-2',
data: 'old',
deliveryToken: 'token-old',
clientGeneration: 1,
ownerGeneration: 1,
ptyIncarnation: 'incarnation-1',
sourceEndSu: 3,
sourceLengthSu: 3
})
await vi.waitFor(() => expect(onRejectedData).toHaveBeenCalledTimes(2))
expect(onData).not.toHaveBeenCalled()
expect(onRejectedData).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ sourceMalformed: true })
)
expect(onRejectedData).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
sourceRejected: true,
source: expect.objectContaining({
deliveryToken: 'token-old',
clientGeneration: 1,
ownerGeneration: 1
})
})
)
})
it('never resolves an incarnation for a rejected frame', async () => {
const mux = {
onNotification: vi.fn(),
request: vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 }))
}
const rejectedDataListeners = new Set()
const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`)
subscribeSshPtyNotifications({
mux: mux as never,
toAppPtyId: (id) => `ssh:conn@@${id}`,
dataListeners: new Set() as never,
rejectedDataListeners: rejectedDataListeners as never,
replayListeners: new Set() as never,
exitListeners: new Set() as never,
livePtyIds: new Set(),
recordExit: vi.fn(),
providerGeneration: 7,
resolvePtyIncarnation,
peekPtyIncarnation: () => undefined
})
const handler = mux.onNotification.mock.calls[0]?.[0] as (
method: string,
params: Record<string, unknown>
) => void
const onRejectedData = vi.fn()
rejectedDataListeners.add(onRejectedData)
// Why this matters: resolvePtyIncarnation caches what it mints and rememberPtyIncarnation is
// first-write-wins, so a synthetic id minted here would fence the PTY's real incarnation off for
// the rest of the generation.
handler('pty.data', { id: 'pty-1', data: 'bad', deliveryToken: 'token-1', sourceEndSu: 4 })
await vi.waitFor(() => expect(onRejectedData).toHaveBeenCalledOnce())
expect(onRejectedData).toHaveBeenCalledOnce()
expect(onRejectedData).toHaveBeenCalledWith(expect.objectContaining({ sourceMalformed: true }))
expect(resolvePtyIncarnation).not.toHaveBeenCalled()
})
it('coalesces repeated exact rejections into one fresh-activation recovery', async () => {
const mux = {
onNotification: vi.fn(),
request: vi.fn(async () => ({ canceled: true, sentEndSu: 3, creditedEndSu: 0 }))
}
const rejectedDataListeners = new Set()
const subscription = subscribeSshPtyNotifications({
mux: mux as never,
toAppPtyId: (id) => `ssh:conn@@${id}`,
dataListeners: new Set() as never,
rejectedDataListeners: rejectedDataListeners as never,
replayListeners: new Set() as never,
exitListeners: new Set() as never,
livePtyIds: new Set(),
recordExit: vi.fn(),
providerGeneration: 7,
resolvePtyIncarnation: (id) => `incarnation:${id}`,
peekPtyIncarnation: () => 'incarnation-1'
})
subscription
.installReceivingActivation('pty-1', {
status: 'pending',
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
checkpointSourceEndSu: 0,
recoveryEndSu: 0
})
.commit()
const handler = mux.onNotification.mock.calls[0]?.[0] as (
method: string,
params: Record<string, unknown>
) => void
const onRejectedData = vi.fn()
rejectedDataListeners.add(onRejectedData)
const rejected = {
id: 'pty-1',
data: 'gap',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
sourceEndSu: 6,
sourceLengthSu: 3
}
handler('pty.data', rejected)
handler('pty.data', rejected)
await vi.waitFor(() => expect(onRejectedData).toHaveBeenCalledOnce())
expect(onRejectedData).toHaveBeenCalledWith(
expect.objectContaining({ rejectedSourceRecovery: 'fresh-activation' })
)
})
@@ -1,94 +0,0 @@
import { vi, type Mock } from 'vitest'
import {
subscribeSshPtyNotifications,
type SshPtyNotificationSubscription
} from './ssh-pty-notification-routing'
import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation'
type NotificationHandler = (method: string, params: Record<string, unknown>) => void
type CancellationRequest = (
method: string,
params: Record<string, unknown>
) => Promise<{ canceled: boolean; sentEndSu: number; creditedEndSu: number }>
type MockMux = {
onNotification: Mock<(handler: NotificationHandler) => void>
request: Mock<CancellationRequest>
}
export type SshPtyNotificationTestSubscription = {
handler: NotificationHandler
mux: MockMux
toAppPtyId: Mock<(id: string) => string>
dataListeners: Set<(payload: { id: string; data: string }) => void>
replayListeners: Set<(payload: { id: string; data: string }) => void>
exitListeners: Set<(payload: { id: string; code: number }) => void>
livePtyIds: Set<string>
recordExit: Mock<(relayPtyId: string, incarnationId: unknown) => void>
resolvePtyIncarnation: Mock<(id: string) => string>
installReceivingActivation: SshPtyNotificationSubscription['installReceivingActivation']
}
export function createSubscription(): SshPtyNotificationTestSubscription {
const mux: MockMux = {
onNotification: vi.fn<(handler: NotificationHandler) => void>(),
request: vi.fn<CancellationRequest>(async () => ({
canceled: true,
sentEndSu: 0,
creditedEndSu: 0
}))
}
const dataListeners = new Set<(payload: { id: string; data: string }) => void>()
const replayListeners = new Set<(payload: { id: string; data: string }) => void>()
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
const livePtyIds = new Set<string>()
const recordExit = vi.fn<(relayPtyId: string, incarnationId: unknown) => void>()
const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`)
const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`)
const subscription = subscribeSshPtyNotifications({
mux: mux as never,
toAppPtyId,
dataListeners: dataListeners as never,
replayListeners: replayListeners as never,
exitListeners: exitListeners as never,
livePtyIds,
recordExit,
providerGeneration: 7,
resolvePtyIncarnation,
peekPtyIncarnation: () => undefined
})
const handler = mux.onNotification.mock.calls[0]?.[0]
if (!handler) {
throw new Error('notification handler was not registered')
}
return {
handler,
mux,
toAppPtyId,
dataListeners,
replayListeners,
exitListeners,
livePtyIds,
recordExit,
resolvePtyIncarnation,
installReceivingActivation: subscription.installReceivingActivation
}
}
export function sourceActivation(
overrides: Partial<PtySourceReceivingActivation> = {}
): PtySourceReceivingActivation {
return Object.freeze({
status: 'pending',
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
checkpointSourceEndSu: 0,
recoveryEndSu: 0,
...overrides
})
}
@@ -1,468 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createSubscription, sourceActivation } from './ssh-pty-notification-routing-test-fixture'
describe('subscribeSshPtyNotifications', () => {
it('ignores non-PTY notifications without mapping params.id', () => {
const { handler, toAppPtyId } = createSubscription()
expect(() => handler('workspace.changed', { snapshot: { revision: 1 } })).not.toThrow()
expect(() =>
handler('fs.changed', {
events: [{ kind: 'update', absolutePath: '/tmp/repo/file.txt' }]
})
).not.toThrow()
expect(toAppPtyId).not.toHaveBeenCalled()
})
it('routes pty.data after validating the string id', () => {
const { handler, toAppPtyId, dataListeners, livePtyIds } = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
handler('pty.data', { id: 'pty-1', data: 'hello', rawLength: 5, seq: 9 })
expect(toAppPtyId).toHaveBeenCalledWith('pty-1')
expect(livePtyIds.has('ssh:conn@@pty-1')).toBe(true)
expect(onData).toHaveBeenCalledWith({
id: 'ssh:conn@@pty-1',
data: 'hello',
providerGeneration: 7,
ptyIncarnation: 'incarnation:pty-1',
sequenceChars: 5,
seq: 9
})
})
it('records pty.exit with the validated relay id', () => {
const { handler, exitListeners, livePtyIds, recordExit } = createSubscription()
const onExit = vi.fn()
exitListeners.add(onExit)
livePtyIds.add('ssh:conn@@pty-1')
handler('pty.exit', {
id: 'pty-1',
code: 0,
incarnationId: 'incarnation-1'
})
expect(recordExit).toHaveBeenCalledWith('pty-1', 'incarnation-1')
expect(livePtyIds.has('ssh:conn@@pty-1')).toBe(false)
expect(onExit).toHaveBeenCalledWith({
id: 'ssh:conn@@pty-1',
code: 0,
providerGeneration: 7,
ptyIncarnation: 'incarnation:pty-1',
incarnationId: 'incarnation-1'
})
})
it('derives exact immutable source ranges and cancels malformed frames without side effects', () => {
const {
handler,
mux,
dataListeners,
livePtyIds,
toAppPtyId,
resolvePtyIncarnation,
installReceivingActivation
} = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
livePtyIds.add('ssh:conn@@unrelated')
installReceivingActivation(
'pty-1',
sourceActivation({ checkpointSourceEndSu: 10, recoveryEndSu: 14 })
).commit()
handler('pty.data', {
id: 'pty-1',
data: 'data',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 14,
sourceLengthSu: 4
})
const acceptedSource = onData.mock.calls[0]?.[0].source
expect(Object.isFrozen(acceptedSource)).toBe(true)
const liveBeforeMalformed = new Set(livePtyIds)
toAppPtyId.mockClear()
resolvePtyIncarnation.mockClear()
handler('pty.data', {
id: 'pty-1',
data: 'bad',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 17,
sourceLengthSu: 4
})
expect(onData.mock.calls[0]?.[0]).toMatchObject({
source: {
relayPtyId: 'pty-1',
spanId: 'token-1:10:14',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
sourceStartSu: 10,
sourceEndSu: 14
}
})
expect(onData).toHaveBeenCalledTimes(1)
expect(livePtyIds).toEqual(liveBeforeMalformed)
expect(toAppPtyId).not.toHaveBeenCalled()
expect(resolvePtyIncarnation).not.toHaveBeenCalled()
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1'
})
})
it('keeps exact source incarnation independent without mutating legacy delivery state', () => {
const { handler, dataListeners, resolvePtyIncarnation, installReceivingActivation } =
createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
handler('pty.data', { id: 'pty-1', data: 'legacy' })
installReceivingActivation(
'pty-1',
sourceActivation({ checkpointSourceEndSu: 0, recoveryEndSu: 4 })
).commit()
handler('pty.data', {
id: 'pty-1',
data: 'data',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 4,
sourceLengthSu: 4
})
expect(onData.mock.calls.map(([payload]) => payload.ptyIncarnation)).toEqual([
'incarnation:pty-1',
'incarnation-1'
])
expect(resolvePtyIncarnation).toHaveBeenCalledTimes(1)
expect(resolvePtyIncarnation).toHaveBeenCalledWith('pty-1', undefined)
})
it('drops stale delivery generations without touching their PTY or unrelated PTYs', () => {
const {
handler,
mux,
dataListeners,
livePtyIds,
toAppPtyId,
resolvePtyIncarnation,
installReceivingActivation
} = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
livePtyIds.add('ssh:conn@@unrelated')
installReceivingActivation(
'pty-1',
sourceActivation({
clientGeneration: 4,
ownerGeneration: 5,
deliveryToken: 'token-new',
recoveryEndSu: 3
})
).commit()
handler('pty.data', {
id: 'pty-1',
data: 'new',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-new',
clientGeneration: 4,
ownerGeneration: 5,
sourceEndSu: 3,
sourceLengthSu: 3
})
const liveBeforeStale = new Set(livePtyIds)
toAppPtyId.mockClear()
resolvePtyIncarnation.mockClear()
handler('pty.data', {
id: 'pty-1',
data: 'old',
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-old',
clientGeneration: 3,
ownerGeneration: 4,
sourceEndSu: 6,
sourceLengthSu: 3
})
expect(onData).toHaveBeenCalledTimes(1)
expect(livePtyIds).toEqual(liveBeforeStale)
expect(toAppPtyId).not.toHaveBeenCalled()
expect(resolvePtyIncarnation).not.toHaveBeenCalled()
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-1',
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'token-old'
})
})
it('rejects same-generation token changes and source discontinuities', () => {
const { handler, mux, dataListeners, installReceivingActivation } = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
const sourceParams = {
id: 'pty-1',
ptyIncarnation: 'incarnation-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceLengthSu: 3
}
installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit()
handler('pty.data', {
...sourceParams,
data: 'one',
deliveryToken: 'token-1',
sourceEndSu: 3
})
handler('pty.data', {
...sourceParams,
data: 'two',
deliveryToken: 'token-2',
sourceEndSu: 6
})
handler('pty.data', {
...sourceParams,
data: 'gap',
deliveryToken: 'token-1',
sourceEndSu: 9
})
handler('pty.data', {
...sourceParams,
data: 'two',
deliveryToken: 'token-1',
sourceEndSu: 6
})
expect(onData.mock.calls.map((call) => call[0].data)).toEqual(['one', 'two'])
expect(mux.request).toHaveBeenCalledTimes(2)
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-2'
})
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1'
})
})
it('accepts a strictly newer rotation, rejects late old data, and preserves new continuity', () => {
const { handler, mux, dataListeners, installReceivingActivation } = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
const frame = (
data: string,
deliveryToken: string,
clientGeneration: number,
ownerGeneration: number,
sourceEndSu: number
) => ({
id: 'pty-1',
data,
ptyIncarnation: 'incarnation-1',
deliveryToken,
clientGeneration,
ownerGeneration,
sourceEndSu,
sourceLengthSu: data.length
})
installReceivingActivation(
'pty-1',
sourceActivation({ deliveryToken: 'token-old', recoveryEndSu: 3 })
).commit()
handler('pty.data', frame('old', 'token-old', 2, 3, 3))
installReceivingActivation(
'pty-1',
sourceActivation({
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'token-new',
checkpointSourceEndSu: 10,
recoveryEndSu: 13
})
).commit()
handler('pty.data', frame('new', 'token-new', 3, 4, 13))
handler('pty.data', frame('old', 'token-old', 2, 3, 6))
handler('pty.data', frame('next', 'token-new', 3, 4, 17))
expect(onData.mock.calls.map((call) => call[0].data)).toEqual(['old', 'new', 'next'])
expect(mux.request).toHaveBeenCalledTimes(1)
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-old'
})
})
it.each([
['client-only advance', 3, 3, 'token-client'],
['owner-only advance', 2, 4, 'token-owner'],
['crossed generations', 3, 2, 'token-crossed'],
['replayed client generation', 1, 4, 'token-replayed'],
['reused token on newer generations', 3, 4, 'token-current']
])(
'rejects a %s without replacing the accepted continuity record',
(_case, clientGeneration, ownerGeneration, deliveryToken) => {
const {
handler,
mux,
dataListeners,
livePtyIds,
toAppPtyId,
resolvePtyIncarnation,
installReceivingActivation
} = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
const base = {
id: 'pty-1',
ptyIncarnation: 'incarnation-1',
sourceLengthSu: 3
}
installReceivingActivation(
'pty-1',
sourceActivation({ deliveryToken: 'token-current', recoveryEndSu: 3 })
).commit()
handler('pty.data', {
...base,
data: 'one',
deliveryToken: 'token-current',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 3
})
const liveBeforeInvalid = new Set(livePtyIds)
toAppPtyId.mockClear()
resolvePtyIncarnation.mockClear()
handler('pty.data', {
...base,
data: 'bad',
deliveryToken,
clientGeneration,
ownerGeneration,
sourceEndSu: 6
})
handler('pty.data', {
...base,
data: 'two',
deliveryToken: 'token-current',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 6
})
expect(onData.mock.calls.map((call) => call[0].data)).toEqual(['one', 'two'])
expect(livePtyIds).toEqual(liveBeforeInvalid)
expect(toAppPtyId).toHaveBeenCalledTimes(1)
expect(resolvePtyIncarnation).not.toHaveBeenCalled()
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-1',
clientGeneration,
ownerGeneration,
deliveryToken
})
}
)
it('does not cancel an incomplete malformed identity or mutate provider state', () => {
const { handler, mux, dataListeners, livePtyIds, toAppPtyId, resolvePtyIncarnation } =
createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
livePtyIds.add('ssh:conn@@unrelated')
handler('pty.data', {
id: 'pty-1',
data: 'bad',
ptyIncarnation: 'incarnation-1',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 3,
sourceLengthSu: 3
})
expect(onData).not.toHaveBeenCalled()
expect(livePtyIds).toEqual(new Set(['ssh:conn@@unrelated']))
expect(toAppPtyId).not.toHaveBeenCalled()
expect(resolvePtyIncarnation).not.toHaveBeenCalled()
expect(mux.request).not.toHaveBeenCalled()
})
it('ignores PTY methods with missing ids', () => {
const { handler, toAppPtyId, dataListeners } = createSubscription()
const onData = vi.fn()
dataListeners.add(onData)
expect(() => handler('pty.data', { data: 'orphan' })).not.toThrow()
expect(toAppPtyId).not.toHaveBeenCalled()
expect(onData).not.toHaveBeenCalled()
})
it('leaves recovery and cancellation control methods to their dedicated handlers', () => {
const {
handler,
mux,
toAppPtyId,
dataListeners,
replayListeners,
exitListeners,
livePtyIds,
recordExit,
resolvePtyIncarnation
} = createSubscription()
const onData = vi.fn()
const onReplay = vi.fn()
const onExit = vi.fn()
dataListeners.add(onData)
replayListeners.add(onReplay)
exitListeners.add(onExit)
livePtyIds.add('ssh:conn@@unrelated')
for (const method of [
'pty.recoveryData',
'pty.recoveryComplete',
'pty.restoreRequired',
'pty.deliveryCanceled'
]) {
handler(method, {
id: 'pty-1',
data: 'control',
deliveryToken: 'token-1',
clientGeneration: 2,
ownerGeneration: 3
})
}
expect(toAppPtyId).not.toHaveBeenCalled()
expect(resolvePtyIncarnation).not.toHaveBeenCalled()
expect(recordExit).not.toHaveBeenCalled()
expect(onData).not.toHaveBeenCalled()
expect(onReplay).not.toHaveBeenCalled()
expect(onExit).not.toHaveBeenCalled()
expect(livePtyIds).toEqual(new Set(['ssh:conn@@unrelated']))
expect(mux.request).not.toHaveBeenCalled()
})
})
@@ -1,269 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation'
import type {
SshPtyDataCallback,
SshPtyExitCallback,
SshPtyReplayCallback
} from './ssh-pty-provider-contract'
import { parseSshPtySourceFrame } from './ssh-pty-source-frame'
import { SshPtySourceDeliveryLedger } from './ssh-pty-source-delivery-ledger'
import type {
PendingSshPtySourceData,
SshPtyRejectedSourceRecovery
} from './ssh-pty-source-delivery-state'
export type { SshPtyDataCallback, SshPtyExitCallback, SshPtyReplayCallback }
export type SshPtyRecoveryActivationLease = Readonly<{
commit: () => void
retire: () => void
}>
export type SshPtyReceivingActivationLease = Readonly<{
commit: () => void
rollback: () => Promise<boolean>
transferToRecovery: (sink: SshPtyDataCallback) => SshPtyRecoveryActivationLease
}>
export type SshPtyNotificationSubscription = Readonly<{
dispose: () => void
installReceivingActivation: (
relayPtyId: string,
activation: PtySourceReceivingActivation
) => SshPtyReceivingActivationLease
}>
export function subscribeSshPtyNotifications(args: {
mux: SshChannelMultiplexer
toAppPtyId: (id: string) => string
dataListeners: Set<SshPtyDataCallback>
rejectedDataListeners?: Set<SshPtyDataCallback>
replayListeners: Set<SshPtyReplayCallback>
exitListeners: Set<SshPtyExitCallback>
livePtyIds: Set<string>
recordExit: (relayPtyId: string, incarnationId: unknown) => void
providerGeneration: number
resolvePtyIncarnation: (relayPtyId: string, incarnationId?: unknown) => string
peekPtyIncarnation: (relayPtyId: string) => string | undefined
}): SshPtyNotificationSubscription {
const toDataPayload = (
pending: PendingSshPtySourceData,
incarnationOverride?: string
): Parameters<SshPtyDataCallback>[0] => {
const id = args.toAppPtyId(pending.relayPtyId)
const ptyIncarnation =
incarnationOverride ??
(pending.source
? (pending.params.ptyIncarnation as string)
: args.resolvePtyIncarnation(pending.relayPtyId, pending.params.incarnationId))
return {
id,
data: pending.data,
providerGeneration: args.providerGeneration,
ptyIncarnation,
...(typeof pending.params.rawLength === 'number'
? { sequenceChars: pending.params.rawLength }
: {}),
...(pending.params.transformed === true ? { transformed: true } : {}),
...(typeof pending.params.seq === 'number' ? { seq: pending.params.seq } : {}),
...(pending.source ? { source: pending.source } : {})
}
}
const publishData = (pending: PendingSshPtySourceData): void => {
const payload = toDataPayload(pending)
args.livePtyIds.add(payload.id)
for (const listener of args.dataListeners) {
listener(payload)
}
}
// Why: a rejected frame is diagnostic and must never mint an incarnation. resolvePtyIncarnation
// caches what it synthesizes and rememberPtyIncarnation is first-write-wins, so a malformed frame
// that lands before the PTY's first good one would pin a `legacy:` id the real attach can never
// displace — fencing every later frame of that generation off as a mismatch.
const rejectedPtyIncarnation = (pending: PendingSshPtySourceData): string => {
const offered = pending.params.ptyIncarnation
if (typeof offered === 'string' && offered.length > 0) {
return offered
}
return args.peekPtyIncarnation(pending.relayPtyId) ?? ''
}
const publishRejectedData = (
pending: PendingSshPtySourceData,
rejection: 'malformed' | 'unadmitted',
recovery: SshPtyRejectedSourceRecovery
): void => {
const listeners = args.rejectedDataListeners
if (!listeners || listeners.size === 0) {
return
}
const payload = {
...toDataPayload(pending, rejectedPtyIncarnation(pending)),
...(rejection === 'malformed' ? { sourceMalformed: true } : {}),
...(rejection === 'unadmitted' ? { sourceRejected: true } : {}),
rejectedSourceRecovery: recovery
}
for (const listener of listeners) {
listener(payload)
}
}
const sourceDeliveries = new SshPtySourceDeliveryLedger(args.mux, publishData)
const rejectedPublications = new Map<
string,
{
pending: number
payload: PendingSshPtySourceData
rejection: 'malformed' | 'unadmitted'
recovery?: SshPtyRejectedSourceRecovery
}
>()
const rejectSourceData = (
pending: PendingSshPtySourceData,
rejection: 'malformed' | 'unadmitted'
): void => {
const batch = rejectedPublications.get(pending.relayPtyId) ?? {
pending: 0,
payload: pending,
rejection
}
batch.pending++
rejectedPublications.set(pending.relayPtyId, batch)
void sourceDeliveries
.reject(pending.relayPtyId, rejectedSourceIdentity(pending.params))
.then((recovery) => {
if (
!batch.recovery ||
rejectedRecoveryPriority(recovery) > rejectedRecoveryPriority(batch.recovery)
) {
batch.payload = pending
batch.rejection = rejection
batch.recovery = recovery
}
})
.finally(() => {
batch.pending--
queueMicrotask(() => {
if (batch.pending > 0 || rejectedPublications.get(pending.relayPtyId) !== batch) {
return
}
rejectedPublications.delete(pending.relayPtyId)
publishRejectedData(batch.payload, batch.rejection, batch.recovery ?? 'reconnect-channel')
})
})
}
const dispose = args.mux.onNotification((method, params) => {
// Why: mux delivers every method to generic handlers; non-PTY payloads
// (workspace.changed, fs.changed, …) have no `id` and must not reach
// toAppPtyId → startsWith.
if (method !== 'pty.exit' && method !== 'pty.data' && method !== 'pty.replay') {
return
}
if (typeof params.id !== 'string' || params.id.length === 0) {
return
}
const relayPtyId = params.id
if (method === 'pty.exit') {
const id = args.toAppPtyId(relayPtyId)
const ptyIncarnation = args.resolvePtyIncarnation(relayPtyId, params.incarnationId)
rejectedPublications.delete(relayPtyId)
args.recordExit(relayPtyId, params.incarnationId)
args.livePtyIds.delete(id)
sourceDeliveries.recordExit(relayPtyId)
for (const listener of args.exitListeners) {
listener({
id,
code: params.code as number,
providerGeneration: args.providerGeneration,
ptyIncarnation,
...(isPtyIncarnationId(params.incarnationId)
? { incarnationId: params.incarnationId }
: {})
})
}
return
}
if (method === 'pty.replay') {
const id = args.toAppPtyId(relayPtyId)
args.livePtyIds.add(id)
for (const listener of args.replayListeners) {
listener({ id, data: params.data as string })
}
return
}
const data = typeof params.data === 'string' ? params.data : ''
const sourceFrame = parseSshPtySourceFrame(params, data, relayPtyId)
if (sourceFrame.malformed) {
const pending = Object.freeze({ relayPtyId, params, data })
rejectSourceData(pending, 'malformed')
return
}
const pending = Object.freeze({
relayPtyId,
params,
data,
source: sourceFrame.source
})
if (sourceFrame.source) {
if (!sourceDeliveries.admit({ ...pending, source: sourceFrame.source })) {
rejectSourceData(pending, 'unadmitted')
}
return
}
publishData(pending)
})
return Object.freeze({
dispose: () => {
rejectedPublications.clear()
dispose()
},
installReceivingActivation: (relayPtyId, activation) => {
const lease = sourceDeliveries.install(relayPtyId, activation)
return Object.freeze({
commit: lease.commit,
rollback: lease.rollback,
transferToRecovery: (sink: SshPtyDataCallback) =>
lease.transferToRecovery((pending) => sink(toDataPayload(pending)))
})
}
})
}
function rejectedRecoveryPriority(recovery: SshPtyRejectedSourceRecovery): number {
if (recovery === 'reconnect-channel') {
return 3
}
return recovery === 'fresh-activation' ? 2 : 1
}
function rejectedSourceIdentity(params: {
deliveryToken?: unknown
clientGeneration?: unknown
ownerGeneration?: unknown
ptyIncarnation?: unknown
}):
| Readonly<{
deliveryToken: string
clientGeneration: number
ownerGeneration: number
ptyIncarnation: string
}>
| undefined {
if (
typeof params.deliveryToken !== 'string' ||
params.deliveryToken.length === 0 ||
!positiveSafeInteger(params.clientGeneration) ||
!positiveSafeInteger(params.ownerGeneration) ||
typeof params.ptyIncarnation !== 'string' ||
params.ptyIncarnation.length === 0
) {
return undefined
}
return Object.freeze({
deliveryToken: params.deliveryToken,
clientGeneration: params.clientGeneration,
ownerGeneration: params.ownerGeneration,
ptyIncarnation: params.ptyIncarnation
})
}
function positiveSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) > 0
}
@@ -1,52 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { IPtyProvider, PtyProcessInfo } from './types'
import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
import { mapSshPtyProcessList } from './ssh-agent-session-process-list'
import type { SshPtyProviderOutputState } from './ssh-pty-provider-output-state'
export function createSshPtyProcessLister(
args: Pick<
Parameters<typeof listSshPtyProcesses>[0],
'mux' | 'connectionId' | 'livePtyIds' | 'outputState'
>
): IPtyProvider['listProcesses'] {
return (options) =>
listSshPtyProcesses({
...args,
includeForegroundProcessEvidence: options?.includeForegroundProcessEvidence,
deadlineMs: options?.deadlineMs
})
}
export async function listSshPtyProcesses(
args: Readonly<{
mux: SshChannelMultiplexer
connectionId: string
livePtyIds: Set<string>
outputState: SshPtyProviderOutputState
includeForegroundProcessEvidence?: boolean
deadlineMs?: number
}>
): Promise<PtyProcessInfo[]> {
const result = await args.mux.request(
'pty.listProcesses',
args.includeForegroundProcessEvidence === undefined
? undefined
: { includeForegroundProcessEvidence: args.includeForegroundProcessEvidence },
args.deadlineMs === undefined
? undefined
: { timeoutMs: Math.max(1, args.deadlineMs - Date.now()) }
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the relay answers pty.listProcesses with PtyProcessInfo rows; the mapper rejects unproven ownership.
const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) =>
toAppSshPtyId(args.connectionId, id)
)
for (const process of processes) {
args.livePtyIds.add(process.id)
args.outputState.rememberPtyIncarnation(
toRelaySshPtyId(args.connectionId, process.id),
process.incarnationId
)
}
return processes
}
@@ -1,410 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION,
AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
} from '../../shared/agent-session-host-authority'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { encodeFrame, HEADER_LENGTH, MessageType } from '../ssh/relay-protocol'
import { SshPtyProvider } from './ssh-pty-provider'
function createTransport(): MultiplexerTransport & {
deliver: (data: Buffer) => void
written: Buffer[]
} {
let deliver = (_data: Buffer): void => {}
const written: Buffer[] = []
return {
write: (data) => {
written.push(data)
},
onData: (callback) => {
deliver = callback
},
onClose: () => {},
deliver: (data) => deliver(data),
written
}
}
function rpcFrame(payload: Record<string, unknown>, sequence: number): Buffer {
return encodeFrame(MessageType.Regular, sequence, 0, Buffer.from(JSON.stringify(payload)))
}
function responseFrame(id: number, result: unknown, sequence: number): Buffer {
return rpcFrame({ jsonrpc: '2.0', id, result }, sequence)
}
function notificationFrame(
method: string,
params: Record<string, unknown>,
sequence: number
): Buffer {
return rpcFrame({ jsonrpc: '2.0', method, params }, sequence)
}
function requestPayloads(transport: ReturnType<typeof createTransport>): Record<string, unknown>[] {
return transport.written.flatMap((frame) => {
if (frame[0] !== MessageType.Regular) {
return []
}
const payloadLength = frame.readUInt32BE(9)
return [
JSON.parse(frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength).toString()) as Record<
string,
unknown
>
]
})
}
// Why a macrotask yield: FrameDecoder stops after FRAME_DECODER_MAX_TURN_MS of decoding and
// resumes the rest of the fed bytes from setImmediate, and feed() refuses to drain while that
// continuation is pending. Under load a delivered response can therefore only be decoded a
// macrotask later, which a microtask-only poll can never reach. Matches waitForRequestCount in
// ssh-git-provider-test-harness.
async function waitForValue<T>(produce: () => T | undefined, what: string): Promise<T> {
for (let turn = 0; turn < 20; turn += 1) {
const value = produce()
if (value !== undefined) {
return value
}
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
throw new Error(`never observed: ${what}`)
}
async function waitForRequest(
transport: ReturnType<typeof createTransport>,
method: string
): Promise<Record<string, unknown>> {
return waitForValue(
() => requestPayloads(transport).find((payload) => payload.method === method),
`request ${method}`
)
}
describe('SSH fresh agent-session create operations', () => {
const request = vi.fn()
let provider: SshPtyProvider
beforeEach(() => {
request.mockReset()
provider = new SshPtyProvider('conn-1', {
request,
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn(() => false)
} as never)
})
it('sends operation identity only to a capable relay', async () => {
request.mockImplementation(async (method: string) =>
method === 'pty.getCapabilities'
? { agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION }
: { id: 'pty-operation', incarnationId: 'incarnation-operation' }
)
await provider.spawn({
cols: 80,
rows: 24,
command: 'codex',
agentSessionCreateOperationId: 'a'.repeat(43)
})
expect(request).toHaveBeenNthCalledWith(1, 'pty.getCapabilities', undefined, {
signal: undefined,
timeoutMs: 5_000
})
expect(request).toHaveBeenNthCalledWith(
2,
'pty.spawn',
{
cols: 80,
rows: 24,
cwd: undefined,
env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true' },
command: 'codex',
agentSessionCreateOperationId: 'a'.repeat(43)
},
expect.objectContaining({ beforeResolve: expect.any(Function) })
)
})
it('does not downgrade after structured dispatch reaches an old relay', async () => {
request.mockResolvedValueOnce({})
await expect(
provider.spawn({
cols: 80,
rows: 24,
command: 'codex',
agentSessionCreateOperationId: 'b'.repeat(43)
})
).rejects.toThrow('execution_owner_unavailable')
expect(request).toHaveBeenCalledOnce()
})
it('keeps a client-selected old-relay spawn byte-for-byte legacy', async () => {
request.mockResolvedValueOnce({ id: 'pty-legacy' })
await expect(
provider.spawn({
cols: 80,
rows: 24,
command: 'codex'
})
).resolves.toMatchObject({ id: 'ssh:conn-1@@pty-legacy' })
expect(request).toHaveBeenNthCalledWith(
1,
'pty.spawn',
{
cols: 80,
rows: 24,
cwd: undefined,
env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true' },
command: 'codex'
},
expect.objectContaining({ beforeResolve: expect.any(Function) })
)
})
it('re-probes a negative capability after an in-place relay upgrade', async () => {
request.mockResolvedValueOnce({}).mockResolvedValueOnce({
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
})
await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(false)
await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(true)
expect(request).toHaveBeenCalledTimes(2)
})
it('keeps a shared capability probe alive when one waiter disconnects', async () => {
let finishProbe!: (result: { agentSessionCreateOperationVersion: number }) => void
request.mockReturnValueOnce(
new Promise((resolve) => {
finishProbe = resolve
})
)
const abort = new AbortController()
const canceled = provider.supportsAgentSessionCreateOperations({ signal: abort.signal })
const live = provider.supportsAgentSessionCreateOperations()
abort.abort()
await expect(canceled).resolves.toBe(false)
finishProbe({
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
})
await expect(live).resolves.toBe(true)
expect(request).toHaveBeenCalledOnce()
})
it('does not dispatch create after cancellation during its capability gate', async () => {
let finishProbe!: (result: { agentSessionCreateOperationVersion: number }) => void
request.mockReturnValueOnce(
new Promise((resolve) => {
finishProbe = resolve
})
)
const abort = new AbortController()
const spawn = provider.spawn({
cols: 80,
rows: 24,
command: 'codex',
agentSessionCreateOperationId: 'd'.repeat(43),
signal: abort.signal
})
abort.abort()
finishProbe({
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
})
await expect(spawn).rejects.toThrow('client_disconnected')
expect(request.mock.calls.map((call) => call[0])).toEqual(['pty.getCapabilities'])
})
it('fences a malformed successful structured-create response', async () => {
request
.mockResolvedValueOnce({
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
})
.mockResolvedValueOnce({ id: 'pty-without-incarnation' })
const failure = await provider
.spawn({
cols: 80,
rows: 24,
command: 'codex',
agentSessionCreateOperationId: 'c'.repeat(43)
})
.catch((error: unknown) => error)
expect(failure).toMatchObject({
message: 'execution_owner_unavailable',
agentSessionOperationOutcome: 'unknown'
})
expect(request).toHaveBeenCalledTimes(2)
})
it('withholds same-turn source data until claim validation and isolates rollback', async () => {
const transport = createTransport()
const mux = new SshChannelMultiplexer(transport)
const exactProvider = new SshPtyProvider('conn-1', mux)
const onData = vi.fn()
exactProvider.onData(onData)
const claim = {
digestVersion: 1 as const,
keyId: 'key',
identityDigest: 'a'.repeat(43),
worktreeScopeDigest: 'b'.repeat(43),
agent: 'codex' as const
}
const surface = {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
const spawn = exactProvider.spawn({
cols: 80,
rows: 24,
agentSessionEnsure: { claim, surface }
})
const capabilityRequest = await waitForRequest(transport, 'pty.getCapabilities')
transport.deliver(
responseFrame(
capabilityRequest.id as number,
{ agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION },
1
)
)
const spawnRequest = await waitForRequest(transport, 'pty.spawn')
const oldActivation = {
status: 'pending',
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-old',
deliveryToken: 'token-old',
checkpointSourceEndSu: 0,
recoveryEndSu: 3
}
transport.deliver(
Buffer.concat([
responseFrame(
spawnRequest.id as number,
{
id: 'pty-1',
incarnationId: 'incarnation-old',
sourceActivation: oldActivation,
agentSessionEnsure: {
disposition: 'created',
owner: {
claim: { ...claim, identityDigest: 'c'.repeat(43) },
generation: 'generation-old',
phase: 'live',
ptyId: 'pty-1',
surface
}
}
},
2
),
notificationFrame(
'pty.data',
{
id: 'pty-1',
data: 'old',
ptyIncarnation: 'incarnation-old',
deliveryToken: 'token-old',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 3,
sourceLengthSu: 3
},
3
)
])
)
expect(onData).not.toHaveBeenCalled()
expect(exactProvider.hasPty('ssh:conn-1@@pty-1')).toBe(false)
const shutdownRequest = await waitForRequest(transport, 'pty.shutdown')
transport.deliver(responseFrame(shutdownRequest.id as number, null, 4))
const cancelRequest = await waitForRequest(transport, 'pty.cancelDelivery')
let spawnSettled = false
void spawn.then(
() => {
spawnSettled = true
},
() => {
spawnSettled = true
}
)
await Promise.resolve()
expect(spawnSettled).toBe(false)
expect(cancelRequest.params).toEqual({
id: 'pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-old'
})
const replacement = exactProvider.spawn({ cols: 80, rows: 24 })
const replacementRequest = requestPayloads(transport).findLast(
(payload) => payload.method === 'pty.spawn'
)
expect(replacementRequest).toBeDefined()
transport.deliver(
Buffer.concat([
responseFrame(
replacementRequest!.id as number,
{
id: 'pty-1',
incarnationId: 'incarnation-new',
sourceActivation: {
...oldActivation,
clientGeneration: 4,
ownerGeneration: 5,
ptyIncarnation: 'incarnation-new',
deliveryToken: 'token-new'
}
},
5
),
notificationFrame(
'pty.data',
{
id: 'pty-1',
data: 'new',
ptyIncarnation: 'incarnation-new',
deliveryToken: 'token-new',
clientGeneration: 4,
ownerGeneration: 5,
sourceEndSu: 3,
sourceLengthSu: 3
},
6
)
])
)
await expect(replacement).resolves.toMatchObject({ incarnationId: 'incarnation-new' })
// The same-buffer data frame can decode a decoder turn after the spawn response resolves.
await waitForValue(() => onData.mock.calls.at(0), 'replacement source data')
expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['new'])
transport.deliver(
responseFrame(
cancelRequest.id as number,
{ canceled: true, sentEndSu: 3, creditedEndSu: 0 },
7
)
)
await expect(spawn).rejects.toThrow('agent_session_ownership_unknown')
expect(exactProvider.hasPty('ssh:conn-1@@pty-1')).toBe(true)
expect(
requestPayloads(transport).filter((payload) => payload.method === 'pty.cancelDelivery')
).toHaveLength(1)
mux.dispose()
})
})
@@ -1,56 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
import { SshPtyProvider } from './ssh-pty-provider'
describe('SSH claimed PTY incarnation validation', () => {
it('retires a created owner with an invalid incarnation identity', async () => {
const claim = {
digestVersion: 1 as const,
keyId: 'key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex' as const
}
const surface = {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
const request = vi.fn(async (method: string) => {
if (method === 'pty.getCapabilities') {
return { agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION }
}
if (method === 'pty.spawn') {
return {
id: 'pty-invalid-incarnation',
incarnationId: 'i'.repeat(129),
agentSessionEnsure: {
disposition: 'created',
owner: {
claim,
generation: 'generation-invalid-incarnation',
phase: 'live',
ptyId: 'pty-invalid-incarnation',
surface
}
}
}
}
return undefined
})
const provider = new SshPtyProvider('conn-1', {
request,
notify: vi.fn(),
onNotification: vi.fn()
} as never)
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('agent_session_ownership_unknown')
expect(request).toHaveBeenCalledWith('pty.shutdown', {
id: 'pty-invalid-incarnation',
immediate: true
})
})
})
@@ -1,46 +0,0 @@
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type RemoteCliBridgeEnv = {
binDir: string
relayDir: string
nodePath: string
sockPath: string
credentialFile?: string
pathDelimiter?: ':' | ';'
}
export type SshPtyDataCallback = (payload: {
id: string
data: string
providerGeneration: number
ptyIncarnation: string
sequenceChars?: number
transformed?: boolean
seq?: number
source?: Readonly<{
relayPtyId: string
spanId: string
clientGeneration: number
ownerGeneration: number
deliveryToken: string
sourceStartSu: number
sourceEndSu: number
}>
sourceMalformed?: boolean
sourceRejected?: boolean
rejectedSourceRecovery?: 'confirm-existing' | 'fresh-activation' | 'reconnect-channel'
}) => void
export type SshPtyReplayCallback = (payload: { id: string; data: string }) => void
export type SshPtyExitCallback = (payload: {
id: string
code: number
providerGeneration: number
ptyIncarnation: string
incarnationId?: PtyIncarnationId
}) => void
export type SshPtyDeliveryPauseAdapter = (args: {
id: string
providerGeneration: number
paused: boolean
}) => void
@@ -1,172 +0,0 @@
import { expect, it, vi } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
function sourceActivation(ptyIncarnation: string) {
return {
status: 'pending' as const,
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation,
deliveryToken: `token:${ptyIncarnation}`,
checkpointSourceEndSu: 0,
recoveryEndSu: 0
}
}
it('rejects a fresh SSH PTY whose exit shares the spawn response batch', async () => {
const mux = {
request: vi.fn(),
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false)
}
const provider = new SshPtyProvider('conn-1', mux as never)
const dataListener = vi.fn()
provider.onData(dataListener)
const exitListener = vi.fn()
provider.onExit(exitListener)
mux.request.mockImplementation(async (method: string, _params, options) => {
if (method === 'pty.spawn') {
const result = {
id: 'pty-raced',
incarnationId: 'incarnation-raced',
sourceActivation: sourceActivation('incarnation-raced')
}
options?.beforeResolve?.(result)
const notify = mux.onNotification.mock.calls[0]?.[0]
notify?.('pty.data', {
id: 'pty-raced',
data: 'data',
ptyIncarnation: 'incarnation-raced',
deliveryToken: 'token:incarnation-raced',
clientGeneration: 2,
ownerGeneration: 3,
sourceEndSu: 4,
sourceLengthSu: 4
})
notify?.('pty.exit', {
id: 'pty-raced',
code: 0,
incarnationId: 'incarnation-raced'
})
return result
}
if (method === 'pty.cancelDelivery') {
return { canceled: true, sentEndSu: 4, creditedEndSu: 0 }
}
return undefined
})
await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow(
'agent_session_exited_during_start'
)
expect(exitListener).toHaveBeenCalledWith({
id: 'ssh:conn-1@@pty-raced',
code: 0,
incarnationId: 'incarnation-raced',
providerGeneration: expect.any(Number),
ptyIncarnation: 'incarnation-raced'
})
expect(dataListener).not.toHaveBeenCalled()
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-raced',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token:incarnation-raced'
})
mux.request.mockResolvedValue({ id: 'pty-next', incarnationId: 'incarnation-next' })
await expect(provider.spawn({ cols: 80, rows: 24 })).resolves.toMatchObject({
id: 'ssh:conn-1@@pty-next',
incarnationId: 'incarnation-next'
})
})
it('rejects an SSH reattach whose matching exit shares the attach reply batch', async () => {
const mux = {
request: vi.fn(),
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false)
}
const provider = new SshPtyProvider('conn-1', mux as never)
mux.request.mockImplementation(async (method: string, _params, options) => {
if (method === 'pty.attach') {
const result = {
incarnationId: 'incarnation-existing',
sourceActivation: sourceActivation('incarnation-existing')
}
options?.beforeResolve?.(result)
const notify = mux.onNotification.mock.calls[0]?.[0]
notify?.('pty.exit', {
id: 'pty-existing',
code: 0,
incarnationId: 'incarnation-existing'
})
return result
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, sessionId: 'ssh:conn-1@@pty-existing' })
).rejects.toThrow('agent_session_exited_during_start')
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-existing',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token:incarnation-existing'
})
mux.request.mockResolvedValue({ incarnationId: 'incarnation-next' })
await expect(
provider.spawn({ cols: 80, rows: 24, sessionId: 'ssh:conn-1@@pty-existing' })
).resolves.toMatchObject({
id: 'ssh:conn-1@@pty-existing',
incarnationId: 'incarnation-next',
isReattach: true
})
})
it('returns a provisional source activation lease to reconnect authority', async () => {
const mux = {
request: vi.fn(),
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false)
}
const provider = new SshPtyProvider('conn-1', mux as never)
const response = {
incarnationId: 'incarnation-reconnect',
sourceActivation: {
...sourceActivation('incarnation-reconnect'),
deliveryToken: 'token-reconnect',
checkpointSourceEndSu: 4,
recoveryEndSu: 8
}
}
mux.request.mockImplementation(async (method: string, _params, options) => {
if (method === 'pty.cancelDelivery') {
return { canceled: true, sentEndSu: 8, creditedEndSu: 4 }
}
if (method !== 'pty.attach') {
return undefined
}
options?.beforeResolve?.(response)
return response
})
const result = await provider.attachForReconnect('ssh:conn-1@@pty-1')
await result.sourceActivationLease?.rollback()
expect(Object.isFrozen(result.sourceActivation)).toBe(true)
expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-reconnect'
})
})
@@ -1,29 +0,0 @@
import { expect, vi } from 'vitest'
/** The relay channel SshPtyProvider talks to, stubbed for the provider's own tests. */
export type MockMultiplexer = {
request: ReturnType<typeof vi.fn>
notify: ReturnType<typeof vi.fn>
onNotification: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
isDisposed: ReturnType<typeof vi.fn>
}
export function createMockMux(): MockMultiplexer {
return {
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false)
}
}
export const sourceActivationRequestOptions = expect.objectContaining({
beforeResolve: expect.any(Function)
})
/** Asserts one request was made, ignoring arguments past the ones named. */
export function expectRequest(request: ReturnType<typeof vi.fn>, ...expected: unknown[]): void {
expect(request.mock.calls.map((call) => call.slice(0, expected.length))).toContainEqual(expected)
}
@@ -1,156 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type {
SshPtyDataCallback,
SshPtyDeliveryPauseAdapter,
SshPtyExitCallback,
SshPtyReplayCallback
} from './ssh-pty-provider-contract'
import {
subscribeSshPtyNotifications,
type SshPtyNotificationSubscription,
type SshPtyReceivingActivationLease
} from './ssh-pty-notification-routing'
import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation'
export class SshPtyProviderOutputState {
private readonly dataListeners = new Set<SshPtyDataCallback>()
private readonly rejectedDataListeners = new Set<SshPtyDataCallback>()
private readonly replayListeners = new Set<SshPtyReplayCallback>()
private readonly exitListeners = new Set<SshPtyExitCallback>()
private readonly incarnationByRelayPtyId = new Map<string, string>()
private readonly pausedRelayPtyIds = new Set<string>()
private deliveryPauseAdapter: SshPtyDeliveryPauseAdapter | null = null
private legacyIncarnationSerial = 1
private subscription: SshPtyNotificationSubscription | null
constructor(
private readonly providerGeneration: number,
args: {
mux: SshChannelMultiplexer
toAppPtyId: (id: string) => string
livePtyIds: Set<string>
recordExit: (relayPtyId: string, incarnationId: unknown) => void
}
) {
this.subscription = subscribeSshPtyNotifications({
...args,
dataListeners: this.dataListeners,
rejectedDataListeners: this.rejectedDataListeners,
replayListeners: this.replayListeners,
exitListeners: this.exitListeners,
providerGeneration,
resolvePtyIncarnation: (relayPtyId, incarnationId) =>
this.resolvePtyIncarnation(relayPtyId, incarnationId),
peekPtyIncarnation: (relayPtyId) => this.incarnationByRelayPtyId.get(relayPtyId),
recordExit: (relayPtyId, incarnationId) => {
args.recordExit(relayPtyId, incarnationId)
this.incarnationByRelayPtyId.delete(relayPtyId)
this.pausedRelayPtyIds.delete(relayPtyId)
}
})
}
dispose(): void {
this.resumePausedDeliveries()
this.subscription?.dispose()
this.subscription = null
this.dataListeners.clear()
this.rejectedDataListeners.clear()
this.replayListeners.clear()
this.exitListeners.clear()
this.incarnationByRelayPtyId.clear()
this.deliveryPauseAdapter = null
}
onData(callback: SshPtyDataCallback): () => void {
this.dataListeners.add(callback)
return () => this.dataListeners.delete(callback)
}
onRejectedData(callback: SshPtyDataCallback): () => void {
this.rejectedDataListeners.add(callback)
return () => this.rejectedDataListeners.delete(callback)
}
onReplay(callback: SshPtyReplayCallback): () => void {
this.replayListeners.add(callback)
return () => this.replayListeners.delete(callback)
}
onExit(callback: SshPtyExitCallback): () => void {
this.exitListeners.add(callback)
return () => this.exitListeners.delete(callback)
}
setDeliveryPauseAdapter(adapter: SshPtyDeliveryPauseAdapter | null): void {
if (adapter !== this.deliveryPauseAdapter) {
this.resumePausedDeliveries()
}
this.deliveryPauseAdapter = adapter
}
hasDeliveryPauseAdapter(): boolean {
return this.deliveryPauseAdapter !== null
}
pause(id: string): void {
if (!this.deliveryPauseAdapter || this.pausedRelayPtyIds.has(id)) {
return
}
this.pausedRelayPtyIds.add(id)
this.deliveryPauseAdapter({ id, providerGeneration: this.providerGeneration, paused: true })
}
resume(id: string): void {
if (!this.deliveryPauseAdapter || !this.pausedRelayPtyIds.delete(id)) {
return
}
this.deliveryPauseAdapter({ id, providerGeneration: this.providerGeneration, paused: false })
}
installReceivingActivation(
relayPtyId: string,
activation: PtySourceReceivingActivation
): SshPtyReceivingActivationLease {
if (!this.subscription) {
throw new Error('ssh_source_receiving_activation_disposed')
}
return this.subscription.installReceivingActivation(relayPtyId, activation)
}
rememberPtyIncarnation(relayPtyId: string, incarnationId: unknown): void {
if (
!this.incarnationByRelayPtyId.has(relayPtyId) &&
typeof incarnationId === 'string' &&
incarnationId.length > 0
) {
this.incarnationByRelayPtyId.set(relayPtyId, incarnationId)
}
}
private resolvePtyIncarnation(relayPtyId: string, incarnationId: unknown): string {
this.rememberPtyIncarnation(relayPtyId, incarnationId)
let resolved = this.incarnationByRelayPtyId.get(relayPtyId)
if (!resolved) {
resolved = `legacy:${this.providerGeneration}:${this.legacyIncarnationSerial++}:${relayPtyId}`
this.incarnationByRelayPtyId.set(relayPtyId, resolved)
}
return resolved
}
private resumePausedDeliveries(): void {
const adapter = this.deliveryPauseAdapter
if (!adapter) {
this.pausedRelayPtyIds.clear()
return
}
for (const id of this.pausedRelayPtyIds) {
try {
adapter({ id, providerGeneration: this.providerGeneration, paused: false })
} catch {
/* Generation close is the fallback cleanup proof. */
}
}
this.pausedRelayPtyIds.clear()
}
}
@@ -1,223 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
type MockMultiplexer = {
request: ReturnType<typeof vi.fn>
notify: ReturnType<typeof vi.fn>
onNotification: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
isDisposed: ReturnType<typeof vi.fn>
}
function createMockMux(): MockMultiplexer {
return {
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn(),
onNotification: vi.fn().mockReturnValue(vi.fn()),
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false)
}
}
describe('SshPtyProvider process listings and events', () => {
let mux: MockMultiplexer
let provider: SshPtyProvider
const scopedPty1 = 'ssh:conn-1@@pty-1'
beforeEach(() => {
mux = createMockMux()
provider = new SshPtyProvider('conn-1', mux as never)
})
it('scopes process listings and bounds the relay RPC by the teardown deadline', async () => {
const processes = [{ id: 'pty-1', cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' }]
mux.request.mockResolvedValue(processes)
await expect(provider.listProcesses()).resolves.toEqual([
{ id: scopedPty1, cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' }
])
expect(mux.request).toHaveBeenLastCalledWith('pty.listProcesses', undefined, undefined)
vi.useFakeTimers()
try {
mux.request.mockResolvedValue([])
await provider.listProcesses({ deadlineMs: Date.now() + 4321 })
expect(mux.request).toHaveBeenLastCalledWith('pty.listProcesses', undefined, {
timeoutMs: 4321
})
} finally {
vi.useRealTimers()
}
})
it('scopes recovered claim owner ids with their SSH connection', async () => {
mux.request.mockResolvedValue([
{
id: 'pty-1',
incarnationId: 'incarnation-1',
cwd: '/home',
title: 'codex',
agentSessionOwners: [
{
claim: {
digestVersion: 1,
keyId: 'key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex'
},
generation: 'generation-1',
phase: 'live',
ptyId: 'pty-1',
surface: {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
}
]
}
])
await expect(provider.listProcesses()).resolves.toMatchObject([
{
id: scopedPty1,
incarnationId: 'incarnation-1',
agentSessionOwners: [{ ptyId: scopedPty1 }]
}
])
})
it('rejects recovered claimed owners without PTY incarnation proof', async () => {
mux.request.mockResolvedValue([
{
id: 'pty-1',
cwd: '/home',
title: 'codex',
agentSessionOwners: [
{
claim: {
digestVersion: 1,
keyId: 'key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex'
},
generation: 'generation-1',
phase: 'live',
ptyId: 'pty-1',
surface: {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
}
]
}
])
await expect(provider.listProcesses()).rejects.toThrow('agent_session_ownership_unknown')
})
it('forwards data, replay, and incarnation-aware exit notifications', () => {
const dataHandler = vi.fn()
const replayHandler = vi.fn()
const exitHandler = vi.fn()
provider.onData(dataHandler)
provider.onReplay(replayHandler)
provider.onExit(exitHandler)
const notify = mux.onNotification.mock.calls[0][0]
notify('pty.data', { id: 'pty-1', data: 'output' })
notify('pty.data', { id: 'pty-1', data: '', rawLength: 9, seq: 9, transformed: true })
notify('pty.replay', { id: 'pty-1', data: 'buffered output' })
notify('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' })
expect(dataHandler).toHaveBeenNthCalledWith(1, {
id: scopedPty1,
data: 'output',
providerGeneration: 1,
ptyIncarnation: 'legacy:1:1:pty-1'
})
expect(dataHandler).toHaveBeenNthCalledWith(2, {
id: scopedPty1,
data: '',
providerGeneration: 1,
ptyIncarnation: 'legacy:1:1:pty-1',
sequenceChars: 9,
seq: 9,
transformed: true
})
expect(replayHandler).toHaveBeenCalledWith({ id: scopedPty1, data: 'buffered output' })
expect(exitHandler).toHaveBeenCalledWith({
id: scopedPty1,
code: 0,
providerGeneration: 1,
ptyIncarnation: 'legacy:1:1:pty-1',
incarnationId: 'incarnation-1'
})
})
it('keeps fallback admission identity stable when spawn metadata arrives later', async () => {
const dataHandler = vi.fn()
provider.onData(dataHandler)
const notify = mux.onNotification.mock.calls[0][0]
notify('pty.data', { id: 'pty-1', data: 'before-response' })
mux.request.mockResolvedValue({ id: 'pty-1', incarnationId: 'incarnation-1' })
await provider.spawn({ cols: 80, rows: 24 })
notify('pty.data', { id: 'pty-1', data: 'after-response' })
expect(dataHandler.mock.calls.map(([payload]) => payload.ptyIncarnation)).toEqual([
'legacy:1:1:pty-1',
'legacy:1:1:pty-1'
])
})
it('supports listener removal, fanout, and connection namespaces', () => {
const removed = vi.fn()
const first = vi.fn()
const second = vi.fn()
const unsubscribe = provider.onData(removed)
provider.onData(first)
provider.onData(second)
unsubscribe()
mux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'first' })
expect(removed).not.toHaveBeenCalled()
expect(first).toHaveBeenCalled()
expect(second).toHaveBeenCalled()
const otherMux = createMockMux()
const otherProvider = new SshPtyProvider('conn-2', otherMux as never)
const other = vi.fn()
otherProvider.onData(other)
otherMux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'second' })
expect(other).toHaveBeenCalledWith({
id: 'ssh:conn-2@@pty-1',
data: 'second',
providerGeneration: 1,
ptyIncarnation: 'legacy:1:1:pty-1'
})
})
it('scopes pause delivery adapters and resumes them during cleanup', () => {
const adapter = vi.fn()
provider.setPtyDeliveryPauseAdapter(adapter)
provider.pauseProducer(scopedPty1)
provider.pauseProducer(scopedPty1)
provider.resumeProducer(scopedPty1)
provider.pauseProducer(scopedPty1)
provider.dispose()
expect(adapter.mock.calls).toEqual([
[{ id: 'pty-1', providerGeneration: 1, paused: true }],
[{ id: 'pty-1', providerGeneration: 1, paused: false }],
[{ id: 'pty-1', providerGeneration: 1, paused: true }],
[{ id: 'pty-1', providerGeneration: 1, paused: false }]
])
})
})
@@ -1,53 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR } from './ssh-pty-errors'
import { SshPtyProvider } from './ssh-pty-provider'
describe('SSH PTY provider session reattach incarnation', () => {
it('remembers the authoritative incarnation before a legacy exit arrives', async () => {
let notify: ((method: string, params: Record<string, unknown>) => void) | undefined
const mux = {
request: vi.fn().mockResolvedValue({ incarnationId: 'incarnation-reattached' }),
notify: vi.fn(),
onNotification: vi.fn(
(callback: (method: string, params: Record<string, unknown>) => void) => {
notify = callback
return vi.fn()
}
)
}
const provider = new SshPtyProvider('conn-1', mux as never)
const onExit = vi.fn()
provider.onExit(onExit)
await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })
notify?.('pty.exit', { id: 'pty-old', code: 0 })
expect(onExit).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ssh:conn-1@@pty-old',
ptyIncarnation: 'incarnation-reattached'
})
)
})
it('fails closed without claiming expiry when reattach requires source restoration', async () => {
const mux = {
request: vi.fn().mockResolvedValue({
incarnationId: 'incarnation-reattached',
sourceRecovery: {
status: 'restoreRequired',
reason: 'checkpointUnavailable'
}
}),
notify: vi.fn(),
onNotification: vi.fn().mockReturnValue(vi.fn())
}
const provider = new SshPtyProvider('conn-1', mux as never)
// The relay proved the PTY alive before answering restoreRequired, so the rejection must not
// carry the token that makes callers retire the binding and cold-restore the agent.
await expect(provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })).rejects.toThrow(
new RegExp(`^${SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR}: pty-old`)
)
})
})
@@ -1,97 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { PtyProcessInspection } from './pty-process-inspection'
import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write'
import type { WriteSettlement } from '../../shared/pty-write-settlement'
import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply'
type SshPtyProviderRpcContext = {
mux: SshChannelMultiplexer
toRelayPtyId: (id: string) => string
}
/** RPC leaves that only need the SSH mux and relay-id mapping. */
export function createSshPtyProviderRpcOperations({ mux, toRelayPtyId }: SshPtyProviderRpcContext) {
return {
deleteWorktreeHistory: async (worktreeId: string): Promise<void> => {
await mux.request('pty.deleteWorktreeHistory', { worktreeId })
},
write: (id: string, data: string): boolean => writeToSshPty(mux, toRelayPtyId(id), data),
writeWithSettlement: (id: string, data: string): Promise<WriteSettlement> =>
writeToSshPtyWithSettlement(mux, toRelayPtyId(id), data),
resize: (id: string, cols: number, rows: number): void => {
mux.notify('pty.resize', { id: toRelayPtyId(id), cols, rows })
},
sendSignal: async (id: string, signal: string): Promise<void> => {
await mux.request('pty.sendSignal', { id: toRelayPtyId(id), signal })
},
getCwd: async (id: string): Promise<string> => {
const result = await mux.request('pty.getCwd', { id: toRelayPtyId(id) })
return result as string
},
getInitialCwd: async (id: string): Promise<string> => {
const result = await mux.request('pty.getInitialCwd', { id: toRelayPtyId(id) })
return result as string
},
clearBuffer: async (id: string): Promise<void> => {
await mux.request('pty.clearBuffer', { id: toRelayPtyId(id) })
},
resetInputModes: async (id: string): Promise<void> => {
await mux.request('pty.resetInputModes', { id: toRelayPtyId(id) })
},
closeStartupQueryAuthority: async (id: string): Promise<number> => {
const result = (await mux.request('pty.closeStartupQueryAuthority', {
id: toRelayPtyId(id)
})) as { appliedSeq?: number }
return result.appliedSeq ?? 0
},
// A notification, because an older relay drops unknown ones instead of failing.
setColorQueryReplyColors: (colors: TerminalOscColorQueryReplyColors): void => {
mux.notify('pty.setColorQueryReplyColors', { colors })
},
acknowledgeDataEvent: (id: string, charCount: number): void => {
mux.notify('pty.ackData', { id: toRelayPtyId(id), charCount })
},
hasChildProcesses: async (id: string): Promise<boolean> => {
const result = await mux.request('pty.hasChildProcesses', { id: toRelayPtyId(id) })
return result as boolean
},
getForegroundProcess: async (id: string): Promise<string | null> => {
const result = await mux.request('pty.getForegroundProcess', { id: toRelayPtyId(id) })
return result as string | null
},
// Do NOT in-flight coalesce this the way the sibling git reads are: the host mints one
// `observationEpoch` per request and the pane foreground reader commits it per read, so a
// shared reply reads as a stale replay and degrades a `live` identity read to `unverifiable`.
// Guarded by ssh-pty-inspect-observation-identity.test.ts; #17525 removes the poll.
inspectProcess: async (
id: string,
options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean }
): Promise<PtyProcessInspection> => {
return (await mux.request('pty.inspectProcess', {
id: toRelayPtyId(id),
...(options?.expectedIncarnationId
? { expectedIncarnationId: options.expectedIncarnationId }
: {}),
// Additive request member: an older relay ignores it and answers as it always did.
...(options?.scanChildProcesses === true ? { scanChildProcesses: true } : {})
})) as PtyProcessInspection
},
serialize: async (ids: string[]): Promise<string> => {
const result = await mux.request('pty.serialize', {
ids: ids.map((id) => toRelayPtyId(id))
})
return result as string
},
revive: async (state: string): Promise<void> => {
await mux.request('pty.revive', { state })
},
getDefaultShell: async (): Promise<string> => {
const result = await mux.request('pty.getDefaultShell')
return result as string
},
getProfiles: async (): Promise<{ name: string; path: string }[]> => {
const result = await mux.request('pty.getProfiles')
return result as { name: string; path: string }[]
}
}
}
@@ -1,659 +0,0 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env'
import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress'
import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
import {
createMockMux,
expectRequest,
sourceActivationRequestOptions,
type MockMultiplexer
} from './ssh-pty-provider-mock-multiplexer'
// Split from ssh-pty-provider.test.ts, which spawn alone had grown past the max-lines ratchet.
// Spawn owns the startup contract — ingress version, env scrubbing, execution ownership, and the
// reconnect races — so it reads as its own unit rather than as an overflow file.
let mux: MockMultiplexer
let provider: SshPtyProvider
const scopedPty1 = 'ssh:conn-1@@pty-1'
beforeEach(() => {
mux = createMockMux()
provider = new SshPtyProvider('conn-1', mux as never)
})
describe('spawn', () => {
const claim = {
digestVersion: 1 as const,
keyId: 'key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex' as const
}
const surface = {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
it('proves relay claim support before a claimed spawn', async () => {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.getCapabilities') {
return {
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
}
}
if (method === 'pty.spawn') {
return {
id: 'pty-1',
incarnationId: 'incarnation-1',
agentSessionEnsure: {
disposition: 'created',
owner: {
claim,
generation: 'generation-1',
phase: 'live',
ptyId: 'pty-1',
surface
}
}
}
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).resolves.toMatchObject({
id: scopedPty1,
agentSessionEnsure: { owner: { ptyId: scopedPty1 } }
})
expect(mux.request.mock.calls.map((call) => call[0])).toEqual([
'pty.getCapabilities',
'pty.spawn'
])
})
it('fails before spawn when the relay cannot prove claim support', async () => {
mux.request.mockRejectedValue(new Error('method not found'))
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('agent_session_claim_unavailable')
expect(mux.request).toHaveBeenCalledTimes(1)
expect(mux.request).not.toHaveBeenCalledWith('pty.spawn', expect.anything())
})
it('fails closed without killing when a claimed response omits its disposition', async () => {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.getCapabilities') {
return {
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
}
}
if (method === 'pty.spawn') {
return { id: 'pty-unclaimed' }
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('execution_owner_unavailable')
expect(mux.request).not.toHaveBeenCalledWith('pty.shutdown', expect.anything())
})
it.each([
{
name: 'PTY identity',
mutate: (owner: Record<string, unknown>) => ({ ...owner, ptyId: 'other-pty' })
},
{
name: 'claim',
mutate: (owner: Record<string, unknown>) => ({
...owner,
claim: { ...claim, identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' }
})
},
{
name: 'fresh surface',
mutate: (owner: Record<string, unknown>) => ({
...owner,
surface: { ...surface, tabId: 'other-tab' }
})
}
])('physically retires a created owner with mismatched $name', async ({ mutate }) => {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.getCapabilities') {
return {
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
}
}
if (method === 'pty.spawn') {
return {
id: 'pty-malformed',
incarnationId: 'incarnation-malformed',
agentSessionEnsure: {
disposition: 'created',
owner: mutate({
claim,
generation: 'generation-malformed',
phase: 'live',
ptyId: 'pty-malformed',
surface
})
}
}
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('agent_session_ownership_unknown')
expectRequest(mux.request, 'pty.shutdown', {
id: 'pty-malformed',
immediate: true
})
})
it('does not kill a canonical adopted owner when its response is semantically invalid', async () => {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.getCapabilities') {
return {
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
}
}
if (method === 'pty.spawn') {
return {
id: 'pty-canonical',
incarnationId: 'incarnation-canonical',
agentSessionEnsure: {
disposition: 'adopted',
owner: {
claim: { ...claim, identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' },
generation: 'generation-canonical',
phase: 'live',
ptyId: 'pty-canonical',
surface
}
}
}
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('agent_session_ownership_unknown')
expect(mux.request).not.toHaveBeenCalledWith('pty.shutdown', expect.anything())
})
it('retains the unavailable fence when physical cleanup cannot be proven', async () => {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.getCapabilities') {
return {
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
}
}
if (method === 'pty.spawn') {
return {
id: 'pty-malformed',
incarnationId: 'incarnation-malformed',
agentSessionEnsure: {
disposition: 'created',
owner: {
claim,
generation: 'generation-malformed',
phase: 'live',
ptyId: 'other-pty',
surface
}
}
}
}
if (method === 'pty.shutdown') {
throw new Error('Timed out waiting for PTY process exit')
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('execution_owner_unavailable')
})
it('sends pty.spawn request through multiplexer', async () => {
mux.request.mockResolvedValue({ id: 'pty-1' })
const result = await provider.spawn({ cols: 80, rows: 24 })
expectRequest(mux.request, 'pty.spawn', {
cols: 80,
rows: 24,
cwd: undefined,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }
})
expect(result).toEqual({ id: scopedPty1 })
expect(provider.hasPty(scopedPty1)).toBe(true)
})
it('keeps a spawned PTY live across an overlapping stale process list', async () => {
mux.request.mockResolvedValueOnce({ id: 'pty-new' }).mockResolvedValueOnce([])
const result = await provider.spawn({ cols: 80, rows: 24 })
await provider.listProcesses()
expect(provider.hasPty(result.id)).toBe(true)
})
it('gates fresh startup intent with the relay ingress capability version', async () => {
mux.request.mockResolvedValue({ id: 'pty-1' })
const startupIngress = {
colors: { foreground: '#eeeeee', background: '#111111' },
deadlineMs: 5_000
}
await provider.spawn({ cols: 80, rows: 24, startupIngress })
expectRequest(
mux.request,
'pty.spawn',
expect.objectContaining({
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
startupIngress
})
)
})
it('passes cwd and env through', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
await provider.spawn({
cols: 120,
rows: 40,
cwd: '/home/user',
env: { FOO: 'bar' }
})
expectRequest(mux.request, 'pty.spawn', {
cols: 120,
rows: 40,
cwd: '/home/user',
env: { FOO: 'bar', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }
})
})
it('forwards trusted agent identity for wrapped remote commands', async () => {
mux.request.mockResolvedValue({ id: 'pty-agent' })
await provider.spawn({
cols: 120,
rows: 40,
command: 'cd /repo && custom-agent-wrapper',
launchAgent: 'claude'
})
expectRequest(
mux.request,
'pty.spawn',
expect.objectContaining({
command: 'cd /repo && custom-agent-wrapper',
launchAgent: 'claude'
})
)
})
it('forwards pane identity as relay metadata on fresh spawn', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
await provider.spawn({
cols: 120,
rows: 40,
paneKey: 'tab-a:leaf-a',
tabId: 'tab-a'
})
expectRequest(mux.request, 'pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
paneKey: 'tab-a:leaf-a',
tabId: 'tab-a'
})
})
it('forwards shell, WSL, and scoped-history options to the relay mux', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
await provider.spawn({
cols: 120,
rows: 40,
shellOverride: 'powershell.exe',
terminalWindowsWslDistro: 'Ubuntu',
worktreeId: 'repo-1::/remote/wt',
historyIsolationEnabled: true
})
expectRequest(mux.request, 'pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
shellOverride: 'powershell.exe',
terminalWindowsWslDistro: 'Ubuntu',
worktreeId: 'repo-1::/remote/wt',
historyIsolationEnabled: true
})
})
it('preserves an explicit remote Powerlevel10k wizard env value', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
await provider.spawn({
cols: 120,
rows: 40,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' }
})
expectRequest(mux.request, 'pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' }
})
})
it('honors requests to delete the remote Powerlevel10k wizard env value', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
await provider.spawn({
cols: 120,
rows: 40,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' },
envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
})
expectRequest(mux.request, 'pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: {},
envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
})
})
it('preserves explicit TERM and forwards final env deletions to the relay', async () => {
mux.request.mockResolvedValue({ id: 'pty-env-precedence' })
const envToDelete = ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV']
await provider.spawn({
cols: 120,
rows: 40,
env: {
TERM: 'screen-256color',
TERM_PROGRAM: 'stale-terminal',
ORCA_STALE_TEST_ENV: '/tmp/stale-env'
},
envToDelete
})
expectRequest(mux.request, 'pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: {
TERM: 'screen-256color',
[POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true'
},
envToDelete
})
const spawnCall = mux.request.mock.calls.find((call) => call[0] === 'pty.spawn')
expect(spawnCall?.[1]?.env).not.toHaveProperty('TERM_PROGRAM')
expect(spawnCall?.[1]?.env).not.toHaveProperty('ORCA_STALE_TEST_ENV')
})
it('forwards provider command delivery to the relay', async () => {
mux.request.mockResolvedValue({ id: 'pty-provider-command' })
await provider.spawn({
cols: 120,
rows: 40,
command: 'echo from-runtime',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
expectRequest(mux.request, 'pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
command: 'echo from-runtime',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
})
it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => {
mux.request.mockResolvedValue({ id: 'pty-bridge' })
provider = new SshPtyProvider('conn-1', mux as never, {
binDir: '/home/user/.orca-relay/bin',
relayDir: '/home/user/.orca-relay/relay-v1',
nodePath: '/usr/bin/node',
sockPath: '/home/user/.orca-relay/relay.sock'
})
await provider.spawn({
cols: 120,
rows: 40,
env: { PATH: '/usr/bin', ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expectRequest(mux.request, 'pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: {
PATH: '/home/user/.orca-relay/bin:/usr/bin',
ORCA_TERMINAL_HANDLE: 'term_ssh',
[POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1',
ORCA_RELAY_NODE_PATH: '/usr/bin/node',
ORCA_RELAY_SOCKET_PATH: '/home/user/.orca-relay/relay.sock'
}
})
})
it('does not clobber the remote relay PATH when caller env has no PATH', async () => {
mux.request.mockResolvedValue({ id: 'pty-bridge' })
provider = new SshPtyProvider('conn-1', mux as never, {
binDir: '/home/user/.orca-relay/bin',
relayDir: '/home/user/.orca-relay/relay-v1',
nodePath: '/usr/bin/node',
sockPath: '/home/user/.orca-relay/relay.sock'
})
await provider.spawn({
cols: 120,
rows: 40,
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expectRequest(mux.request, 'pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: {
ORCA_TERMINAL_HANDLE: 'term_ssh',
[POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1',
ORCA_RELAY_NODE_PATH: '/usr/bin/node',
ORCA_RELAY_SOCKET_PATH: '/home/user/.orca-relay/relay.sock'
}
})
})
it('uses Windows PATH delimiters for native Windows SSH bridge env', async () => {
mux.request.mockResolvedValue({ id: 'pty-bridge' })
provider = new SshPtyProvider('conn-1', mux as never, {
binDir: 'C:/Users/me/.orca-relay/bin',
relayDir: 'C:/Users/me/.orca-remote/relay-v1',
nodePath: 'C:/Program Files/nodejs/node.exe',
sockPath: '\\\\.\\pipe\\orca-relay-123',
pathDelimiter: ';'
})
await provider.spawn({
cols: 120,
rows: 40,
env: { Path: 'C:/Windows/System32;C:/Tools' }
})
expectRequest(mux.request, 'pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: {
Path: 'C:/Users/me/.orca-relay/bin;C:/Windows/System32;C:/Tools',
[POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
ORCA_REMOTE_CLI_BIN_DIR: 'C:/Users/me/.orca-relay/bin',
ORCA_RELAY_DIR: 'C:/Users/me/.orca-remote/relay-v1',
ORCA_RELAY_NODE_PATH: 'C:/Program Files/nodejs/node.exe',
ORCA_RELAY_SOCKET_PATH: '\\\\.\\pipe\\orca-relay-123'
}
})
})
it('reattaches an existing session and returns attach replay separately from snapshot', async () => {
mux.request.mockResolvedValue({
replay: 'buffered-output',
incarnationId: 'incarnation-reattached'
})
const result = await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })
expectRequest(
mux.request,
'pty.attach',
{
id: 'pty-old',
cols: 80,
rows: 24,
suppressReplayNotification: true,
requireReplay: true
},
sourceActivationRequestOptions
)
expect(result).toEqual({
id: 'ssh:conn-1@@pty-old',
isReattach: true,
replay: 'buffered-output',
incarnationId: 'incarnation-reattached'
})
})
it('never sends fresh startup intent on relay reattach', async () => {
mux.request.mockResolvedValue({ replay: 'restored' })
await provider.spawn({
cols: 80,
rows: 24,
sessionId: scopedPty1,
startupIngress: {
colors: { foreground: '#eeeeee', background: '#111111' },
deadlineMs: 5_000
}
})
expectRequest(
mux.request,
'pty.attach',
expect.not.objectContaining({ startupIngress: expect.anything() })
)
expect(mux.request).not.toHaveBeenCalledWith('pty.spawn', expect.anything())
})
it('reattaches scoped app ids using raw relay ids', async () => {
mux.request.mockResolvedValue({ replay: 'buffered-output' })
const result = await provider.spawn({
cols: 80,
rows: 24,
sessionId: 'ssh:conn-1@@pty-old'
})
expectRequest(mux.request, 'pty.attach', {
id: 'pty-old',
cols: 80,
rows: 24,
suppressReplayNotification: true,
requireReplay: true
})
expect(result).toEqual({
id: 'ssh:conn-1@@pty-old',
isReattach: true,
replay: 'buffered-output'
})
})
it('reattaches with explicit pane identity when hook env was stripped', async () => {
mux.request.mockResolvedValue({ replay: 'buffered-output' })
await provider.spawn({
cols: 80,
rows: 24,
sessionId: 'pty-old',
paneKey: 'tab-a:leaf-a',
tabId: 'tab-a'
})
expectRequest(mux.request, 'pty.attach', {
id: 'pty-old',
cols: 80,
rows: 24,
suppressReplayNotification: true,
requireReplay: true,
expectedPaneKey: 'tab-a:leaf-a',
expectedTabId: 'tab-a'
})
})
it('does not fresh-spawn over an expired reattach session', async () => {
mux.request.mockRejectedValueOnce(new Error('PTY "pty-old" not found'))
await expect(provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })).rejects.toThrow(
'SSH_SESSION_EXPIRED: pty-old'
)
expect(mux.request).toHaveBeenNthCalledWith(
1,
'pty.attach',
{
id: 'pty-old',
cols: 80,
rows: 24,
suppressReplayNotification: true,
requireReplay: true
},
sourceActivationRequestOptions
)
expect(mux.request).toHaveBeenCalledTimes(1)
})
it('preserves transient reattach failures for retry handling', async () => {
mux.request.mockRejectedValueOnce(new Error('SSH connection lost, reconnecting...'))
await expect(provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })).rejects.toThrow(
'SSH connection lost, reconnecting...'
)
expect(mux.request).toHaveBeenCalledTimes(1)
})
})
@@ -1,155 +0,0 @@
// The client half of #17830: a spawn refused for an unloadable node-pty must route into a repair
// instead of printing a paragraph. Covers the seam only — the ledger and the locked rebuild are
// tested in src/main/ssh/ssh-relay-node-pty-repair.test.ts and
// src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
import { createMockMux, type MockMultiplexer } from './ssh-pty-provider-mock-multiplexer'
import {
TERMINAL_UNAVAILABLE_RPC_ERROR_CODE,
type TerminalUnavailableCause
} from '../../shared/terminal-unavailable-cause'
const UNAVAILABLE_MESSAGE =
"Remote terminals are unavailable: this host's node-pty binary was built for Node ABI 108."
function cause(overrides: Partial<TerminalUnavailableCause> = {}): TerminalUnavailableCause {
return {
status: 'blocked',
reason: 'abi_mismatch',
detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115',
repairable: true,
host: {
platform: 'linux',
arch: 'x64',
libc: 'glibc',
glibcVersion: '2.31',
nodeAbi: '115',
nodeVersion: 'v20.11.0'
},
...overrides
}
}
/** Shaped exactly as the multiplexer rebuilds a JSON-RPC error response client-side. */
function relayRejection(data: unknown): Error {
const error = new Error(UNAVAILABLE_MESSAGE)
Object.defineProperty(error, 'code', { value: TERMINAL_UNAVAILABLE_RPC_ERROR_CODE })
Object.defineProperty(error, 'data', { value: data })
return error
}
function spawnCallCount(target: MockMultiplexer): number {
return target.request.mock.calls.filter((call) => call[0] === 'pty.spawn').length
}
function rejectSpawnOnce(mux: MockMultiplexer, error: Error): void {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.spawn') {
throw error
}
return undefined
})
}
const SPAWN_OPTS = { cwd: '/repo', cols: 80, rows: 24 }
let mux: MockMultiplexer
let provider: SshPtyProvider
beforeEach(() => {
mux = createMockMux()
provider = new SshPtyProvider('conn-1', mux as never)
})
describe('terminal-unavailable spawn recovery', () => {
it('routes a repairable cause into recovery and retries once on the repaired provider', async () => {
rejectSpawnOnce(mux, relayRejection(cause()))
const repairedMux = createMockMux()
repairedMux.request.mockResolvedValue({ id: 'pty-1', incarnationId: 'incarnation-1' })
const repairedProvider = new SshPtyProvider('conn-1', repairedMux as never)
const recover = vi.fn(async () => repairedProvider)
provider.setTerminalUnavailableRecovery(recover)
const result = await provider.spawn(SPAWN_OPTS)
expect(result.id).toBe('ssh:conn-1@@pty-1')
expect(recover).toHaveBeenCalledTimes(1)
expect(recover).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'abi_mismatch', status: 'blocked' })
)
// Exactly one retry, on the post-repair channel — never a second attempt on the broken one.
expect(spawnCallCount(mux)).toBe(1)
expect(spawnCallCount(repairedMux)).toBe(1)
})
it('surfaces the relay message when the retry still fails, and does not recurse into a second repair', async () => {
// The lock-busy shape: the reconnect happened, the rebuild did not, so the relay says the same thing.
rejectSpawnOnce(mux, relayRejection(cause()))
const degradedMux = createMockMux()
const degradedProvider = new SshPtyProvider('conn-1', degradedMux as never)
rejectSpawnOnce(degradedMux, relayRejection(cause()))
const degradedRecover = vi.fn(async () => degradedProvider)
degradedProvider.setTerminalUnavailableRecovery(degradedRecover)
const recover = vi.fn(async () => degradedProvider)
provider.setTerminalUnavailableRecovery(recover)
await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE)
expect(recover).toHaveBeenCalledTimes(1)
expect(degradedRecover).not.toHaveBeenCalled()
})
it('rethrows without recovery when the recovery declines', async () => {
rejectSpawnOnce(mux, relayRejection(cause()))
provider.setTerminalUnavailableRecovery(async () => null)
await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE)
})
it('never recovers from an unverifiable cause', async () => {
rejectSpawnOnce(mux, relayRejection(cause({ status: 'unverifiable', repairable: true })))
const recover = vi.fn(async () => provider)
provider.setTerminalUnavailableRecovery(recover)
await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE)
expect(recover).not.toHaveBeenCalled()
})
it('never recovers from a toolchain_missing cause', async () => {
rejectSpawnOnce(mux, relayRejection(cause({ reason: 'toolchain_missing', repairable: false })))
const recover = vi.fn(async () => provider)
provider.setTerminalUnavailableRecovery(recover)
await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE)
expect(recover).not.toHaveBeenCalled()
})
it('ignores a malformed cause rather than half-reading it', async () => {
rejectSpawnOnce(mux, relayRejection({ status: 'blocked', repairable: true }))
const recover = vi.fn(async () => provider)
provider.setTerminalUnavailableRecovery(recover)
await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE)
expect(recover).not.toHaveBeenCalled()
})
it('leaves an old relay that publishes no cause on today behaviour', async () => {
rejectSpawnOnce(mux, new Error(UNAVAILABLE_MESSAGE))
const recover = vi.fn(async () => provider)
provider.setTerminalUnavailableRecovery(recover)
await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE)
expect(recover).not.toHaveBeenCalled()
})
it('does not swallow an ordinary spawn failure', async () => {
rejectSpawnOnce(mux, new Error('shell not found'))
const recover = vi.fn(async () => provider)
provider.setTerminalUnavailableRecovery(recover)
await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow('shell not found')
expect(recover).not.toHaveBeenCalled()
})
})
-318
View File
@@ -1,318 +0,0 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
import {
createMockMux,
expectRequest,
type MockMultiplexer
} from './ssh-pty-provider-mock-multiplexer'
describe('SshPtyProvider', () => {
let mux: MockMultiplexer
let provider: SshPtyProvider
const scopedPty1 = 'ssh:conn-1@@pty-1'
beforeEach(() => {
mux = createMockMux()
provider = new SshPtyProvider('conn-1', mux as never)
})
it('reports that SSH panes cannot restore from authoritative provider snapshots', () => {
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(
new Promise((resolve) => {
finishProbe = resolve
})
)
const abort = new AbortController()
const canceled = provider.supportsAgentSessionClaims({ signal: abort.signal })
const live = provider.supportsAgentSessionClaims()
abort.abort()
await expect(canceled).resolves.toBe(false)
finishProbe({ agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION })
await expect(live).resolves.toBe(true)
expect(mux.request).toHaveBeenCalledOnce()
})
it('attach sends pty.attach request', async () => {
await provider.attach(scopedPty1)
expectRequest(mux.request, 'pty.attach', { id: 'pty-1' })
})
it('attachForReconnect returns replay without relay notification', async () => {
mux.request.mockResolvedValue({
replay: 'restored output',
incarnationId: 'incarnation-reconnect'
})
const result = await provider.attachForReconnect(scopedPty1)
expect(result).toEqual({
replay: 'restored output',
incarnationId: 'incarnation-reconnect'
})
expectRequest(
mux.request,
'pty.attach',
{
id: 'pty-1',
suppressReplayNotification: true
},
expect.objectContaining({
timeoutMs: 10_000,
beforeResolve: expect.any(Function)
})
)
})
it('keeps missing incarnation compatible with an old relay', async () => {
mux.request.mockResolvedValue({ replay: 'legacy replay' })
await expect(provider.attachForReconnect(scopedPty1)).resolves.toEqual({
replay: 'legacy replay'
})
})
it('rejects a present malformed attach incarnation', async () => {
mux.request.mockResolvedValue({ incarnationId: '' })
await expect(provider.attachForReconnect(scopedPty1)).rejects.toThrow(
'Invalid SSH PTY attach incarnation'
)
})
it('attachForReconnect forwards expected identity when provided', async () => {
await provider.attachForReconnect(scopedPty1, {
paneKey: 'tab-a:leaf-a',
tabId: 'tab-a'
})
expectRequest(
mux.request,
'pty.attach',
{
id: 'pty-1',
suppressReplayNotification: true,
expectedPaneKey: 'tab-a:leaf-a',
expectedTabId: 'tab-a'
},
expect.objectContaining({
timeoutMs: 10_000,
beforeResolve: expect.any(Function)
})
)
})
it('write sends pty.data notification', () => {
provider.write(scopedPty1, 'hello')
expect(mux.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello' })
})
it('resize sends pty.resize notification', () => {
provider.resize(scopedPty1, 120, 40)
expect(mux.notify).toHaveBeenCalledWith('pty.resize', { id: 'pty-1', cols: 120, rows: 40 })
})
it('reads the applied PTY size from the relay', async () => {
mux.request.mockResolvedValue({ cols: 120, rows: 40 })
await expect(provider.getAppliedSize(scopedPty1)).resolves.toEqual({ cols: 120, rows: 40 })
expectRequest(mux.request, 'pty.getSize', { id: 'pty-1' }, { timeoutMs: 1_000 })
})
it('caches only an old relay method-not-found response', async () => {
mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 }))
await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull()
await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull()
expect(mux.request).toHaveBeenCalledTimes(1)
})
it('retries an applied-size read after a transient relay failure', async () => {
mux.request
.mockRejectedValueOnce(
Object.assign(new Error('connection lost'), { code: 'CONNECTION_LOST' })
)
.mockResolvedValueOnce({ cols: 100, rows: 30 })
await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull()
await expect(provider.getAppliedSize(scopedPty1)).resolves.toEqual({ cols: 100, rows: 30 })
expect(mux.request).toHaveBeenCalledTimes(2)
})
it('shutdown sends pty.shutdown request', async () => {
await provider.shutdown(scopedPty1, { immediate: true })
expectRequest(
mux.request,
'pty.shutdown',
{
id: 'pty-1',
immediate: true,
keepHistory: false
},
undefined
)
})
it('shutdown forwards keepHistory: true over the relay', async () => {
await provider.shutdown(scopedPty1, { immediate: true, keepHistory: true })
expectRequest(
mux.request,
'pty.shutdown',
{
id: 'pty-1',
immediate: true,
keepHistory: true
},
undefined
)
})
it('shutdown forwards the expected PTY incarnation over the relay', async () => {
await provider.shutdown(scopedPty1, {
immediate: true,
expectedIncarnationId: 'incarnation-1'
})
expectRequest(
mux.request,
'pty.shutdown',
{
id: 'pty-1',
immediate: true,
keepHistory: false,
expectedIncarnationId: 'incarnation-1'
},
undefined
)
})
it('shutdown bounds the relay RPC by the teardown deadline', async () => {
// Why: freeze Date.now() so the leaf conversion deadline -> remaining relative
// timeout is exact and the mux receives precisely the leftover budget.
vi.useFakeTimers()
try {
await provider.shutdown(scopedPty1, { immediate: true, deadlineMs: Date.now() + 4321 })
expectRequest(
mux.request,
'pty.shutdown',
{ id: 'pty-1', immediate: true, keepHistory: false },
{ timeoutMs: 4321 }
)
} finally {
vi.useRealTimers()
}
})
it('sendSignal sends pty.sendSignal request', async () => {
await provider.sendSignal(scopedPty1, 'SIGINT')
expectRequest(mux.request, 'pty.sendSignal', { id: 'pty-1', signal: 'SIGINT' })
})
it('getCwd sends pty.getCwd request', async () => {
mux.request.mockResolvedValue('/home/user/project')
const cwd = await provider.getCwd(scopedPty1)
expect(cwd).toBe('/home/user/project')
expectRequest(mux.request, 'pty.getCwd', { id: 'pty-1' })
})
it('clearBuffer sends pty.clearBuffer request', async () => {
await provider.clearBuffer(scopedPty1)
expectRequest(mux.request, 'pty.clearBuffer', { id: 'pty-1' })
})
it('pushes host colours as a notification an older relay can ignore', () => {
const colors = { foreground: '#ffffff', background: '#282c34' }
provider.setColorQueryReplyColors(colors)
expect(mux.notify).toHaveBeenCalledWith('pty.setColorQueryReplyColors', { colors })
expect(mux.request).not.toHaveBeenCalledWith('pty.setColorQueryReplyColors', expect.anything())
})
it('acknowledgeDataEvent sends pty.ackData notification', () => {
provider.acknowledgeDataEvent(scopedPty1, 1024)
expect(mux.notify).toHaveBeenCalledWith('pty.ackData', { id: 'pty-1', charCount: 1024 })
})
it('hasChildProcesses sends request and returns result', async () => {
mux.request.mockResolvedValue(true)
const result = await provider.hasChildProcesses(scopedPty1)
expect(result).toBe(true)
expectRequest(mux.request, 'pty.hasChildProcesses', { id: 'pty-1' })
})
it('getForegroundProcess returns process name', async () => {
mux.request.mockResolvedValue('node')
const result = await provider.getForegroundProcess(scopedPty1)
expect(result).toBe('node')
expectRequest(mux.request, 'pty.getForegroundProcess', { id: 'pty-1' })
})
it('preserves client-only unverifiable process inspection', async () => {
const inspection = {
foregroundProcess: null,
hasChildProcesses: false,
verdict: 'unverifiable' as const,
reason: 'transport_loss' as const
}
mux.request.mockResolvedValue(inspection)
await expect(provider.inspectProcess(scopedPty1)).resolves.toEqual(inspection)
expectRequest(mux.request, 'pty.inspectProcess', { id: 'pty-1' })
})
it('forwards the expected incarnation for fenced remote inspection', async () => {
const inspection = {
foregroundProcess: 'codex',
hasChildProcesses: true,
foregroundProcessEvidence: { verdict: 'live' }
}
mux.request.mockResolvedValue(inspection)
await expect(
provider.inspectProcess(scopedPty1, { expectedIncarnationId: 'incarnation-1' })
).resolves.toEqual(inspection)
expectRequest(mux.request, 'pty.inspectProcess', {
id: 'pty-1',
expectedIncarnationId: 'incarnation-1'
})
})
it('probes the additive foreground-evidence capability', async () => {
mux.request.mockResolvedValue({ foregroundProcessEvidenceVersion: 1 })
await expect(provider.supportsForegroundProcessEvidence()).resolves.toBe(true)
expectRequest(mux.request, 'pty.getCapabilities', undefined, { timeoutMs: 5_000 })
})
it('serializes scoped app ids using raw relay ids', async () => {
mux.request.mockResolvedValue('serialized')
const result = await provider.serialize([scopedPty1])
expect(result).toBe('serialized')
expectRequest(mux.request, 'pty.serialize', { ids: ['pty-1'] })
})
it('rejects scoped ids owned by another SSH connection', async () => {
await expect(provider.shutdown('ssh:conn-2@@pty-1', { immediate: true })).rejects.toThrow(
'belongs to SSH connection "conn-2"'
)
})
it('getDefaultShell returns shell path', async () => {
mux.request.mockResolvedValue('/bin/bash')
const result = await provider.getDefaultShell()
expect(result).toBe('/bin/bash')
})
})
-308
View File
@@ -1,308 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types'
import type { WriteSettlement } from '../../shared/pty-write-settlement'
import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply'
import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size'
import type {
RemoteCliBridgeEnv,
SshPtyDataCallback,
SshPtyDeliveryPauseAdapter,
SshPtyExitCallback,
SshPtyReplayCallback
} from './ssh-pty-provider-contract'
import { SshPtyProviderOutputState } from './ssh-pty-provider-output-state'
import { spawnFreshSshPty } from './ssh-agent-session-create-operation'
import {
requestSshPtyAttach,
reattachSshPtySessionForSpawn,
type PtySourceRecoveryRequest,
type SshPtyAttachResult
} from './ssh-pty-session-reattach'
import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request'
import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities'
import type { PtyProcessInspection } from './pty-process-inspection'
import { spawnWithTerminalRuntimeRepair, type TerminalRepairHook } from './ssh-pty-spawn-repair'
import { createSshPtyProviderRpcOperations } from './ssh-pty-provider-rpc-operations'
import { createSshPtyProcessLister } from './ssh-pty-process-list'
// Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch.
function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined {
return deadlineMs === undefined ? undefined : { timeoutMs: Math.max(1, deadlineMs - Date.now()) }
}
/** Remote PTY provider that proxies IPtyProvider operations through the relay. */
export class SshPtyProvider implements IPtyProvider {
private mux: SshChannelMultiplexer
private connectionId: string
private livePtyIds = new Set<string>()
getAppliedSize: NonNullable<IPtyProvider['getAppliedSize']>
listProcesses: IPtyProvider['listProcesses']
private readonly agentSessionCapabilities: SshAgentSessionCapabilities
private spawnExitRaces = new SshPtySpawnExitRaceTracker()
private readonly outputState: SshPtyProviderOutputState
private recoverFromTerminalUnavailable: TerminalRepairHook<SshPtyProvider> | null = null
private readonly rpcOperations: ReturnType<typeof createSshPtyProviderRpcOperations>
deleteWorktreeHistory = (worktreeId: string): Promise<void> =>
this.rpcOperations.deleteWorktreeHistory(worktreeId)
write = (id: string, data: string): boolean => this.rpcOperations.write(id, data)
writeWithSettlement = (id: string, data: string): Promise<WriteSettlement> =>
this.rpcOperations.writeWithSettlement(id, data)
resize = (id: string, cols: number, rows: number): void =>
this.rpcOperations.resize(id, cols, rows)
sendSignal = (id: string, signal: string): Promise<void> =>
this.rpcOperations.sendSignal(id, signal)
getCwd = (id: string): Promise<string> => this.rpcOperations.getCwd(id)
getInitialCwd = (id: string): Promise<string> => this.rpcOperations.getInitialCwd(id)
clearBuffer = (id: string): Promise<void> => this.rpcOperations.clearBuffer(id)
resetInputModes = (id: string): Promise<void> => this.rpcOperations.resetInputModes(id)
closeStartupQueryAuthority = (id: string): Promise<number> =>
this.rpcOperations.closeStartupQueryAuthority(id)
setColorQueryReplyColors = (colors: TerminalOscColorQueryReplyColors): void =>
this.rpcOperations.setColorQueryReplyColors(colors)
acknowledgeDataEvent = (id: string, charCount: number): void =>
this.rpcOperations.acknowledgeDataEvent(id, charCount)
hasChildProcesses = (id: string): Promise<boolean> => this.rpcOperations.hasChildProcesses(id)
getForegroundProcess = (id: string): Promise<string | null> =>
this.rpcOperations.getForegroundProcess(id)
inspectProcess = (
id: string,
options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean }
): Promise<PtyProcessInspection> => this.rpcOperations.inspectProcess(id, options)
serialize = (ids: string[]): Promise<string> => this.rpcOperations.serialize(ids)
revive = (state: string): Promise<void> => this.rpcOperations.revive(state)
getDefaultShell = (): Promise<string> => this.rpcOperations.getDefaultShell()
getProfiles = (): Promise<{ name: string; path: string }[]> => this.rpcOperations.getProfiles()
requestHostRpc: NonNullable<IPtyProvider['requestHostRpc']> = (method, params, options) =>
this.mux.request(method, params as Record<string, unknown>, options)
constructor(
connectionId: string,
mux: SshChannelMultiplexer,
private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv,
readonly providerGeneration = 1
) {
this.connectionId = connectionId
this.mux = mux
this.rpcOperations = createSshPtyProviderRpcOperations({
mux,
toRelayPtyId: (id) => this.toRelayPtyId(id)
})
this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux)
this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId)
this.outputState = new SshPtyProviderOutputState(providerGeneration, {
mux,
toAppPtyId: (id) => this.toAppPtyId(id),
livePtyIds: this.livePtyIds,
recordExit: (relayPtyId, incarnationId) => {
this.spawnExitRaces.recordExit(relayPtyId, incarnationId)
}
})
this.listProcesses = createSshPtyProcessLister({
mux,
connectionId,
livePtyIds: this.livePtyIds,
outputState: this.outputState
})
}
dispose(): void {
this.outputState.dispose()
this.livePtyIds.clear()
}
getConnectionId = (): string => this.connectionId
canProvideAuthoritativeBufferSnapshot = (_id: string): boolean => false
private toRelayPtyId = (id: string): string => toRelaySshPtyId(this.connectionId, id)
private toAppPtyId = (id: string): string => toAppSshPtyId(this.connectionId, id)
/** Installed by SshRelaySession, which owns the connection, the repair lock and the reconnect. */
setTerminalUnavailableRecovery(recover: TerminalRepairHook<SshPtyProvider>): void {
this.recoverFromTerminalUnavailable = recover
}
hasLivePtys(): boolean {
return this.livePtyIds.size > 0
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
return await spawnWithTerminalRuntimeRepair<SshPtyProvider, PtySpawnResult>({
attempt: () => this.spawnWithoutTerminalRuntimeRepair(opts),
recover: this.recoverFromTerminalUnavailable,
retry: (provider) => provider.spawnWithoutTerminalRuntimeRepair(opts)
})
}
private async spawnWithoutTerminalRuntimeRepair(opts: PtySpawnOptions): Promise<PtySpawnResult> {
if (opts.agentSessionEnsure && opts.sessionId) {
throw new Error('agent_session_claim_unavailable')
}
if (opts.agentSessionEnsure) {
const supportsClaims = await this.supportsAgentSessionClaims({ signal: opts.signal })
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
if (!supportsClaims) {
throw new Error('agent_session_claim_unavailable')
}
}
if (opts.sessionId) {
return await reattachSshPtySessionForSpawn({
mux: this.mux,
connectionId: this.connectionId,
sessionId: opts.sessionId,
options: opts,
exitRaceTracker: this.spawnExitRaces,
installSourceActivation: (relayPtyId, activation) =>
this.outputState.installReceivingActivation(relayPtyId, activation),
rememberPtyIncarnation: (relayPtyId, incarnationId) =>
this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId),
acceptLivePty: (relayPtyId) => this.livePtyIds.add(relayPtyId)
})
}
const supportsCreateOperation = opts.agentSessionCreateOperationId
? await this.supportsAgentSessionCreateOperations({ signal: opts.signal })
: false
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
if (opts.agentSessionCreateOperationId && !supportsCreateOperation) {
// Why: host routing owns legacy selection; a changed relay must not downgrade after dispatch.
throw new Error('execution_owner_unavailable')
}
return await spawnFreshSshPty({
mux: this.mux,
options: opts,
params: buildSshPtySpawnRequest({
options: opts,
remoteCliBridgeEnv: this.remoteCliBridgeEnv,
supportsCreateOperation
}),
exitRaceTracker: this.spawnExitRaces,
installSourceActivation: (id, activation) =>
this.outputState.installReceivingActivation(id, activation),
rememberPtyIncarnation: (id, incarnation) =>
this.outputState.rememberPtyIncarnation(id, incarnation),
acceptLivePty: (id) => this.livePtyIds.add(id),
toAppPtyId: this.toAppPtyId
})
}
async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise<boolean> {
return await this.agentSessionCapabilities.supportsClaims(options)
}
providesAgentSessionOwnerListings(_ptyId: string): boolean {
return this.agentSessionCapabilities.providesOwnerListings()
}
async supportsAgentSessionCreateOperations(
options: { signal?: AbortSignal } = {}
): Promise<boolean> {
return await this.agentSessionCapabilities.supportsCreateOperations(options)
}
async supportsForegroundProcessEvidence(
options: { signal?: AbortSignal } = {}
): Promise<boolean> {
return await this.agentSessionCapabilities.supportsForegroundProcessEvidence(options)
}
async attach(id: string): Promise<void> {
const relayPtyId = this.toRelayPtyId(id)
await requestSshPtyAttach({
mux: this.mux,
relayPtyId,
params: { id: relayPtyId },
commitSourceActivation: true,
installSourceActivation: (ptyId, activation) =>
this.outputState.installReceivingActivation(ptyId, activation),
rememberPtyIncarnation: (ptyId, incarnationId) =>
this.outputState.rememberPtyIncarnation(ptyId, incarnationId)
})
}
async attachForReconnect(
id: string,
expected?: { paneKey?: string; tabId?: string },
sourceRecovery?: PtySourceRecoveryRequest
): Promise<SshPtyAttachResult> {
// Why: reconnect owns replay delivery so stale/duplicate attach results can
// be filtered before they reach the renderer. The expected identity lets the
// relay reject a cross-generation id collision instead of reattaching this
// lease to a different pane's freshly spawned PTY.
const params = {
id: this.toRelayPtyId(id),
suppressReplayNotification: true,
...(sourceRecovery ? { sourceRecovery } : {}),
...(expected?.paneKey ? { expectedPaneKey: expected.paneKey } : {}),
...(expected?.tabId ? { expectedTabId: expected.tabId } : {})
}
const relayPtyId = this.toRelayPtyId(id)
return await requestSshPtyAttach({
mux: this.mux,
relayPtyId,
params,
timeoutMs: 10_000,
installSourceActivation: (ptyId, activation) =>
this.outputState.installReceivingActivation(ptyId, activation),
rememberPtyIncarnation: (ptyId, incarnationId) =>
this.outputState.rememberPtyIncarnation(ptyId, incarnationId)
})
}
async shutdown(id: string, opts: Parameters<IPtyProvider['shutdown']>[1]): Promise<void> {
// Both fences are omitted rather than sent undefined: a host that predates either must see no
// key at all, and the owner fence in particular must never reach it as a falsy claim.
const { expectedIncarnationId, expectedOwnerClientInstanceId } = opts
await this.mux.request(
'pty.shutdown',
{
id: this.toRelayPtyId(id),
immediate: opts.immediate ?? false,
keepHistory: opts.keepHistory ?? false,
...(expectedIncarnationId === undefined ? {} : { expectedIncarnationId }),
...(expectedOwnerClientInstanceId === undefined ? {} : { expectedOwnerClientInstanceId })
},
relayTimeoutOptions(opts.deadlineMs)
)
this.livePtyIds.delete(id)
}
hasPty = (id: string): boolean => this.livePtyIds.has(id)
onData = (callback: SshPtyDataCallback): (() => void) => this.outputState.onData(callback)
onRejectedData = (callback: SshPtyDataCallback): (() => void) =>
this.outputState.onRejectedData(callback)
onReplay = (callback: SshPtyReplayCallback): (() => void) => this.outputState.onReplay(callback)
onExit = (callback: SshPtyExitCallback): (() => void) => this.outputState.onExit(callback)
setPtyDeliveryPauseAdapter(adapter: SshPtyDeliveryPauseAdapter | null): void {
this.outputState.setDeliveryPauseAdapter(adapter)
}
hasPtyDeliveryPauseAdapter(): boolean {
return this.outputState.hasDeliveryPauseAdapter()
}
pauseProducer(id: string): void {
this.outputState.pause(this.toRelayPtyId(id))
}
resumeProducer(id: string): void {
this.outputState.resume(this.toRelayPtyId(id))
}
closeOutputIntake(reason: string): void {
this.mux.dispose('connection_lost')
console.error('[ssh-pty-provider] closed after bounded output intake failure', { reason })
}
}
@@ -1,100 +0,0 @@
// Two refusals still leave `reattachSshPtySessionForSpawn` carrying the same `SSH_SESSION_EXPIRED`
// text, and only one of them observed the process. That text is therefore not a verdict, and a
// caller that tests it with `.includes()` cannot tell "the host says this PTY is gone" from "the id
// names a live PTY that belongs to another pane". The restoreRequired refusal no longer shares the
// token at all — it carries `SSH_PTY_SOURCE_RESTORE_REQUIRED`, because the relay proved that PTY
// alive and only the output delivery was stale.
//
// It matters because the callers that DO test it act destructively: `spawn-execute.ts` expires the
// lease and deletes the in-memory ownership, which between them are the client's only record that a
// remote process exists. Erase both for a live PTY and #9819's sweep finds a host-attested,
// route-less, pane-bound shell on the next connect and SIGKILLs it.
//
// So this pins the discriminator the destructive branch keys on: the type, not the message.
import { describe, expect, it, vi } from 'vitest'
import {
isSshPtyAbsentFromRelayError,
isSshPtyProvenExitedOnRelayError,
SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR,
SSH_SESSION_EXPIRED_ERROR
} from './ssh-pty-errors'
import { PTY_ATTACH_PROVEN_EXITED_MARKER } from '../../shared/pty-attach-absence-evidence'
import { reattachSshPtySessionForSpawn } from './ssh-pty-session-reattach'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
const CONNECTION = 'conn-1'
const SESSION = 'pty-1'
function reattachAgainst(attach: () => Promise<unknown>): Promise<unknown> {
return reattachSshPtySessionForSpawn({
mux: { request: vi.fn(attach) } as unknown as SshChannelMultiplexer,
connectionId: CONNECTION,
sessionId: SESSION,
options: { cols: 80, rows: 24 },
exitRaceTracker: {
begin: () => 1,
didMatchingExitArrive: () => false,
finish: () => {}
} as never,
acceptLivePty: () => {}
})
}
async function refusalFrom(attach: () => Promise<unknown>): Promise<Error> {
try {
await reattachAgainst(attach)
} catch (error) {
return error as Error
}
throw new Error('expected the reattach to be refused')
}
describe('an SSH reattach refusal says whether the host observed the PTY', () => {
it('marks a relay that answered "not found" as positive evidence of absence', async () => {
const error = await refusalFrom(async () => {
throw new Error(`PTY "${SESSION}" not found`)
})
expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR)
expect(isSshPtyAbsentFromRelayError(error)).toBe(true)
// ...but absence from the relay is a union. An unmarked answer is also what a restarted relay
// gives for every id the previous one minted, checked against nothing, so it may not certify a
// death (docs/reference/ssh-execution-boundary.md).
expect(isSshPtyProvenExitedOnRelayError(error)).toBe(false)
})
it('separates the refusal the relay backed with a pid probe', async () => {
const error = await refusalFrom(async () => {
throw new Error(`PTY "${SESSION}" not found (${PTY_ATTACH_PROVEN_EXITED_MARKER})`)
})
expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR)
expect(isSshPtyAbsentFromRelayError(error)).toBe(true)
expect(isSshPtyProvenExitedOnRelayError(error)).toBe(true)
})
it('gives a restoreRequired refusal its own token instead of the expiry text', async () => {
// The PTY attached. The relay answered about it. It is running. Only the source stream could
// not be resumed — see the `restoreRequired` carve-out in ssh-pty-errors.ts. Carrying the
// expiry token here made every `.includes()` caller retire a live pane's binding.
const error = await refusalFrom(async () => ({
incarnationId: '11111111-1111-4111-8111-111111111111',
sourceRecovery: { status: 'restoreRequired', reason: 'checkpoint_unavailable' }
}))
expect(error.message).toContain(SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR)
expect(error.message).not.toContain(SSH_SESSION_EXPIRED_ERROR)
expect(isSshPtyAbsentFromRelayError(error)).toBe(false)
})
it('does not mark an identity mismatch as absence either', async () => {
// The id names a LIVE PTY that belongs to a different pane.
const error = await refusalFrom(async () => {
throw new Error(`PTY "${SESSION}" not found (identity mismatch)`)
})
expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR)
expect(isSshPtyAbsentFromRelayError(error)).toBe(false)
expect(isSshPtyProvenExitedOnRelayError(error)).toBe(false)
})
})
@@ -1,81 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
SSH_PTY_IDENTITY_MISMATCH_ERROR,
SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR,
SSH_SESSION_EXPIRED_ERROR,
isSshPtyAbsentFromRelayError
} from './ssh-pty-errors'
import { SshPtyProvider } from './ssh-pty-provider'
function providerRejectingAttachWith(error: unknown): SshPtyProvider {
const mux = {
request: vi.fn().mockRejectedValue(error),
notify: vi.fn(),
onNotification: vi.fn().mockReturnValue(vi.fn())
}
return new SshPtyProvider('conn-1', mux as never)
}
async function reattachRejection(error: unknown): Promise<unknown> {
const provider = providerRejectingAttachWith(error)
return await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-1' }).then(
() => undefined,
(rejection: unknown) => rejection
)
}
describe('SSH PTY relay absence verdict', () => {
it('reports a relay-delivered not-found as positive absence', async () => {
const rejection = await reattachRejection(new Error('PTY "pty-1" not found'))
expect(isSshPtyAbsentFromRelayError(rejection)).toBe(true)
expect((rejection as Error).message).toBe(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`)
})
it('does not report absence when the id names a live PTY owned by another pane', async () => {
const rejection = await reattachRejection(
new Error('PTY "pty-1" not found (identity mismatch)')
)
expect(isSshPtyAbsentFromRelayError(rejection)).toBe(false)
// Byte-identical to the pre-change message: only the class is new, so the toast humanizer,
// both spawn-execute lease paths, and the renderer's substring checks are untouched.
expect((rejection as Error).message).toBe(
`${SSH_SESSION_EXPIRED_ERROR}: pty-1 ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
)
})
// Loss of contact never observes the process: each of these must stay `unverifiable`.
it.each([
['a lost link', 'SSH connection lost, reconnecting...'],
['a disposed multiplexer', 'Multiplexer disposed'],
['a request timeout', 'Request "pty.attach" timed out after 10000ms']
])('does not report absence for %s', async (_label, message) => {
const rejection = await reattachRejection(new Error(message))
expect(isSshPtyAbsentFromRelayError(rejection)).toBe(false)
expect((rejection as Error).message).toBe(message)
})
it('does not report absence when the PTY is live but its source needs restoring', async () => {
const mux = {
request: vi.fn().mockResolvedValue({
incarnationId: 'incarnation-1',
sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' }
}),
notify: vi.fn(),
onNotification: vi.fn().mockReturnValue(vi.fn())
}
const provider = new SshPtyProvider('conn-1', mux as never)
const rejection = await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-1' }).then(
() => undefined,
(error: unknown) => error
)
expect(isSshPtyAbsentFromRelayError(rejection)).toBe(false)
// Nor may it wear the expiry token: that is what the renderer retires a pane binding on.
expect((rejection as Error).message).not.toContain(SSH_SESSION_EXPIRED_ERROR)
expect((rejection as Error).message).toContain(SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR)
})
})
@@ -1,337 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isPtyIncarnationId, type PtyIncarnationId } from '../../shared/pty-incarnation'
import {
SSH_PTY_IDENTITY_MISMATCH_ERROR,
SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR,
SSH_SESSION_EXPIRED_ERROR,
SshPtyAbsentFromRelayError,
SshPtyProvenExitedOnRelayError,
isSshPtyIdentityMismatchError,
isSshPtyNotFoundError
} from './ssh-pty-errors'
import { isProvenExitedPtyAttachRefusal } from '../../shared/pty-attach-absence-evidence'
import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
import type { PtySpawnOptions, PtySpawnResult } from './types'
import type { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
import type {
PtySourceRecoveryRequest,
PtySourceRecoveryResult
} from '../../shared/pty-source-recovery-contract'
import {
parsePtySourceReceivingActivation,
type PtySourceReceivingActivation
} from '../../shared/pty-source-receiving-activation'
import type { SshPtyReceivingActivationLease } from './ssh-pty-notification-routing'
export type SshPtyAttachResult = {
replay?: string
incarnationId?: PtyIncarnationId
sourceRecovery?: PtySourceRecoveryResult
sourceActivation?: PtySourceReceivingActivation
sourceActivationLease?: SshPtyReceivingActivationLease
}
type SshPtyReattachResult = PtySpawnResult & {
sourceRecovery?: PtySourceRecoveryResult
sourceActivationLease?: SshPtyReceivingActivationLease
}
export function parseSshPtyAttachResult(value: unknown): SshPtyAttachResult {
if (value === undefined || value === null) {
return {}
}
if (typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Invalid SSH PTY attach response')
}
const result = value as {
replay?: unknown
incarnationId?: unknown
sourceRecovery?: unknown
sourceActivation?: unknown
}
if (result.replay !== undefined && typeof result.replay !== 'string') {
throw new Error('Invalid SSH PTY attach replay')
}
if (result.incarnationId !== undefined && !isPtyIncarnationId(result.incarnationId)) {
// Why: a present-but-invalid identity cannot safely fence delayed exits from a reused relay id.
throw new Error('Invalid SSH PTY attach incarnation')
}
const sourceRecovery = parseSourceRecoveryResult(result.sourceRecovery)
const sourceActivation = parsePtySourceReceivingActivation(result.sourceActivation)
const activation =
sourceActivation ?? (sourceRecovery?.status === 'pending' ? sourceRecovery : undefined)
if (
activation &&
(!isPtyIncarnationId(result.incarnationId) ||
activation.ptyIncarnation !== result.incarnationId ||
(sourceRecovery?.status === 'pending' && !sameSourceActivation(activation, sourceRecovery)))
) {
throw new Error('Invalid SSH PTY source activation identity')
}
return {
...(typeof result.replay === 'string' ? { replay: result.replay } : {}),
...(isPtyIncarnationId(result.incarnationId) ? { incarnationId: result.incarnationId } : {}),
...(sourceRecovery ? { sourceRecovery } : {}),
...(activation ? { sourceActivation: activation } : {})
}
}
export async function requestSshPtyAttach(args: {
mux: SshChannelMultiplexer
relayPtyId: string
params: Record<string, unknown>
timeoutMs?: number
commitSourceActivation?: boolean
installSourceActivation?: (
relayPtyId: string,
activation: PtySourceReceivingActivation
) => SshPtyReceivingActivationLease
rememberPtyIncarnation?: (relayPtyId: string, incarnationId: unknown) => void
}): Promise<SshPtyAttachResult> {
let activationLease: SshPtyReceivingActivationLease | undefined
const installFromResult = (result: SshPtyAttachResult): void => {
if (!activationLease && result.sourceActivation && args.installSourceActivation) {
activationLease = args.installSourceActivation(args.relayPtyId, result.sourceActivation)
}
}
try {
const rawResult = await args.mux.request('pty.attach', args.params, {
...(args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs }),
beforeResolve: (value) => installFromResult(parseSshPtyAttachResult(value))
})
const result = parseSshPtyAttachResult(rawResult)
installFromResult(result)
args.rememberPtyIncarnation?.(args.relayPtyId, result.incarnationId)
if (args.commitSourceActivation) {
activationLease?.commit()
}
return {
...result,
...(activationLease ? { sourceActivationLease: activationLease } : {})
}
} catch (error) {
activationLease?.rollback()
throw error
}
}
function parseSourceRecoveryResult(value: unknown): PtySourceRecoveryResult | undefined {
if (value === undefined) {
return undefined
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('Invalid SSH PTY source recovery response')
}
const input = value as Record<string, unknown>
if (input.status === 'restoreRequired' && typeof input.reason === 'string') {
return Object.freeze({ status: 'restoreRequired', reason: input.reason })
}
if (
input.status !== 'pending' ||
typeof input.deliveryToken !== 'string' ||
input.deliveryToken.length === 0 ||
typeof input.ptyIncarnation !== 'string' ||
input.ptyIncarnation.length === 0 ||
!positiveInteger(input.clientGeneration) ||
!positiveInteger(input.ownerGeneration) ||
!nonNegativeInteger(input.checkpointSourceEndSu) ||
!nonNegativeInteger(input.recoveryEndSu) ||
Number(input.recoveryEndSu) < Number(input.checkpointSourceEndSu)
) {
throw new Error('Invalid SSH PTY source recovery response')
}
return Object.freeze({
status: 'pending',
deliveryToken: input.deliveryToken,
ptyIncarnation: input.ptyIncarnation,
clientGeneration: Number(input.clientGeneration),
ownerGeneration: Number(input.ownerGeneration),
checkpointSourceEndSu: Number(input.checkpointSourceEndSu),
recoveryEndSu: Number(input.recoveryEndSu)
})
}
function positiveInteger(value: unknown): boolean {
return Number.isSafeInteger(value) && Number(value) > 0
}
function nonNegativeInteger(value: unknown): boolean {
return Number.isSafeInteger(value) && Number(value) >= 0
}
function sameSourceActivation(
left: PtySourceReceivingActivation,
right: PtySourceReceivingActivation
): boolean {
return (
left.clientGeneration === right.clientGeneration &&
left.ownerGeneration === right.ownerGeneration &&
left.ptyIncarnation === right.ptyIncarnation &&
left.deliveryToken === right.deliveryToken &&
left.checkpointSourceEndSu === right.checkpointSourceEndSu &&
left.recoveryEndSu === right.recoveryEndSu
)
}
export type { PtySourceRecoveryRequest }
const RESTORE_REQUIRED_ATTACH_ATTEMPTS = 2
export async function reattachSshPtySession(args: {
mux: SshChannelMultiplexer
connectionId: string
sessionId: string
options: PtySpawnOptions
rememberPtyIncarnation?: (relayPtyId: string, incarnationId: unknown) => void
installSourceActivation?: (
relayPtyId: string,
activation: PtySourceReceivingActivation
) => SshPtyReceivingActivationLease
}): Promise<SshPtyReattachResult> {
const relaySessionId = toRelaySshPtyId(args.connectionId, args.sessionId)
console.warn(`[ssh-pty] spawn() called with sessionId=${args.sessionId}, attempting pty.attach`)
try {
// Why: expected pane identity prevents a reused relay id from attaching the wrong shell.
const expectedPaneKey = args.options.paneKey ?? args.options.env?.ORCA_PANE_KEY
const expectedTabId = args.options.tabId ?? args.options.env?.ORCA_TAB_ID
const attachResult = await requestSshPtyAttach({
mux: args.mux,
relayPtyId: relaySessionId,
params: {
id: relaySessionId,
cols: args.options.cols,
rows: args.options.rows,
suppressReplayNotification: true,
// A reattach always paints into a NEW terminal: a reconnect bumps tab.generation, which is
// the pane's React key, so TerminalPane remounts and the old xterm is disposed with its
// buffer. Without this the relay sees a delivery still open under our unchanged client id,
// answers "you already have this", and the pane stays blank until new output arrives.
requireReplay: true,
...(expectedPaneKey ? { expectedPaneKey } : {}),
...(expectedTabId ? { expectedTabId } : {})
},
installSourceActivation: args.installSourceActivation,
rememberPtyIncarnation: args.rememberPtyIncarnation
})
console.warn(
`[ssh-pty] pty.attach succeeded for ${args.sessionId}, replay=${!!attachResult.replay}`
)
return {
id: toAppSshPtyId(args.connectionId, relaySessionId),
isReattach: true,
...(attachResult.replay ? { replay: attachResult.replay } : {}),
...(attachResult.incarnationId ? { incarnationId: attachResult.incarnationId } : {}),
...(attachResult.sourceRecovery ? { sourceRecovery: attachResult.sourceRecovery } : {}),
...(attachResult.sourceActivation ? { sourceActivation: attachResult.sourceActivation } : {}),
...(attachResult.sourceActivationLease
? { sourceActivationLease: attachResult.sourceActivationLease }
: {})
}
} catch (error) {
// Why: an expired relay lease must be surfaced distinctly so the renderer clears its binding.
console.warn(`[ssh-pty] pty.attach FAILED for ${args.sessionId}:`, error)
if (isSshPtyNotFoundError(error)) {
if (isSshPtyIdentityMismatchError(error)) {
// The id names a LIVE PTY owned by another pane, so this is not evidence of absence.
throw new Error(
`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId} ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
)
}
// Why the class: the relay answered for this exact id, so callers holding a pane binding may
// retire it and spawn fresh. Plain `SSH_SESSION_EXPIRED` cannot say that — a restarted relay
// renumbers from pty-1, so the message alone is indistinguishable from a lost link.
//
// Why the subclass: the relay marks the one refusal it backed with a pid probe. Without the
// marker the answer is the "no such id" union, which is not evidence the shell ended, so the
// narrow class is minted only when the relay said so (docs/reference/ssh-execution-boundary.md).
if (isProvenExitedPtyAttachRefusal(error)) {
throw new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}`)
}
throw new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}`)
}
throw error
}
}
export async function reattachSshPtySessionWithExitFence(
args: Parameters<typeof reattachSshPtySession>[0] & {
exitRaceTracker: SshPtySpawnExitRaceTracker
}
): Promise<SshPtyReattachResult> {
const operation = args.exitRaceTracker.begin()
let result: SshPtyReattachResult | undefined
try {
result = await reattachSshPtySession(args)
const relayPtyId = toRelaySshPtyId(args.connectionId, result.id)
if (
args.exitRaceTracker.didMatchingExitArrive(operation, {
id: relayPtyId,
incarnationId: result.incarnationId
})
) {
throw new Error('agent_session_exited_during_start')
}
return result
} catch (error) {
result?.sourceActivationLease?.rollback()
throw error
} finally {
args.exitRaceTracker.finish(operation)
}
}
/**
* The full reattach path a spawn takes when it carries a sessionId: fence the
* exit race, reopen a delivery the relay retired, and commit or roll back the
* source-activation lease.
*
* Lives here rather than in SshPtyProvider.spawn so the lease's commit and
* rollback stay in one place — a caller that only wrapped the fence could
* return without committing and silently leak the activation.
*/
export async function reattachSshPtySessionForSpawn(
args: Parameters<typeof reattachSshPtySessionWithExitFence>[0] & {
acceptLivePty: (relayPtyId: string) => void
}
): Promise<PtySpawnResult> {
let restoreRequiredReason = 'unknown'
// The relay retires the stale delivery as it answers restoreRequired, so the next attach opens a
// fresh one with full replay. One immediate retry keeps the ordinary reconnect off the renderer's
// 15s-cooldown pane-recovery ladder.
for (let attempt = 0; attempt < RESTORE_REQUIRED_ATTACH_ATTEMPTS; attempt++) {
let result: SshPtyReattachResult | undefined
try {
result = await reattachSshPtySessionWithExitFence(args)
if (result.sourceRecovery?.status === 'restoreRequired') {
restoreRequiredReason = result.sourceRecovery.reason
const lease = result.sourceActivationLease
// An unconfirmed cancellation must not stack a second delivery on the first.
if (lease && !(await lease.rollback())) {
break
}
continue
}
args.acceptLivePty(result.id)
result.sourceActivationLease?.commit()
const {
sourceActivationLease: _lease,
sourceRecovery: _sourceRecovery,
...spawnResult
} = result
return spawnResult
} catch (error) {
result?.sourceActivationLease?.rollback()
throw error
}
}
// Why not SSH_SESSION_EXPIRED: the relay only reaches a restoreRequired reply after finding the
// managed PTY and confirming its process is alive, so this is `unverifiable` about the delivery
// and positive evidence the PTY is live. Claiming expiry here made the caller retire the pane
// binding and cold-restore the agent into a second copy of a running session.
throw new Error(
`${SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR}: ${toRelaySshPtyId(
args.connectionId,
args.sessionId
)} ${restoreRequiredReason}`
)
}
@@ -1,95 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { SshPtySourceDeliveryLedger } from './ssh-pty-source-delivery-ledger'
describe('SshPtySourceDeliveryLedger', () => {
const activation = (deliveryToken: string) =>
Object.freeze({
status: 'pending' as const,
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
deliveryToken,
checkpointSourceEndSu: 0,
recoveryEndSu: 0
})
it('retains cancellation ownership when recovery transfer is superseded', async () => {
const request = vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 }))
const ledger = new SshPtySourceDeliveryLedger({ request } as never, vi.fn())
const older = ledger.install(
'pty-1',
Object.freeze({
status: 'pending',
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-old',
checkpointSourceEndSu: 0,
recoveryEndSu: 0
})
)
ledger.install(
'pty-1',
Object.freeze({
status: 'pending',
clientGeneration: 3,
ownerGeneration: 4,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-new',
checkpointSourceEndSu: 0,
recoveryEndSu: 0
})
)
expect(() => older.transferToRecovery(vi.fn())).toThrow('ssh_source_receiving_activation_stale')
await expect(older.rollback()).resolves.toBe(true)
expect(request).toHaveBeenCalledOnce()
expect(request).toHaveBeenCalledWith('pty.cancelDelivery', {
id: 'pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-old'
})
})
it('retires an exact rejected activation only after cancellation is proven', async () => {
const request = vi.fn(async () => ({ canceled: true, sentEndSu: 4, creditedEndSu: 0 }))
const ledger = new SshPtySourceDeliveryLedger({ request } as never, vi.fn())
ledger.install('pty-1', activation('token-old')).commit()
await expect(ledger.reject('pty-1', activation('token-old'))).resolves.toBe('fresh-activation')
expect(() => ledger.install('pty-1', activation('token-new')).commit()).not.toThrow()
})
it('keeps the current activation when a stale rejected token cannot cancel it', async () => {
const publish = vi.fn()
const request = vi.fn(async () => {
throw new Error('Unknown or stale PTY source delivery cancellation')
})
const ledger = new SshPtySourceDeliveryLedger({ request } as never, publish)
ledger.install('pty-1', activation('token-current')).commit()
await expect(ledger.reject('pty-1', activation('token-stale'))).resolves.toBe(
'confirm-existing'
)
expect(
ledger.admit({
relayPtyId: 'pty-1',
params: { ptyIncarnation: 'incarnation-1' },
data: 'healthy',
source: {
relayPtyId: 'pty-1',
spanId: 'span-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-current',
sourceStartSu: 0,
sourceEndSu: 7
}
})
).toBe(true)
expect(publish).toHaveBeenCalledWith(expect.objectContaining({ data: 'healthy' }))
})
})
@@ -1,254 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation'
import type { SshPtySourceFrame } from './ssh-pty-source-frame'
import {
acceptsSourceFrame,
activePredecessor,
sameReceivingActivation,
sameRejectedSourceIdentity,
settleExactSourceDeliveryCancellation,
settledReceivingActivationLease,
type PendingSshPtySourceData,
type RejectedSourceIdentity,
type SourceDeliveryLeaseState,
type SourceDeliveryState,
type SshPtyRecoveryActivationLease,
type SshPtyRejectedSourceRecovery,
type SshPtySourceDeliveryLease
} from './ssh-pty-source-delivery-state'
export class SshPtySourceDeliveryLedger {
private readonly deliveryByPty = new Map<string, SourceDeliveryState>()
constructor(
private readonly mux: SshChannelMultiplexer,
private readonly publishData: (pending: PendingSshPtySourceData) => void
) {}
install(relayPtyId: string, activation: PtySourceReceivingActivation): SshPtySourceDeliveryLease {
if (!relayPtyId || activation.ptyIncarnation.length === 0) {
throw new Error('ssh_source_receiving_activation_invalid')
}
const previous = this.deliveryByPty.get(relayPtyId)
if (previous && sameReceivingActivation(previous.activation, activation)) {
if (previous.lease.phase !== 'committed') {
throw new Error('ssh_source_receiving_activation_stale')
}
return settledReceivingActivationLease()
}
if (
previous &&
(activation.clientGeneration <= previous.activation.clientGeneration ||
activation.ownerGeneration <= previous.activation.ownerGeneration ||
activation.deliveryToken === previous.activation.deliveryToken)
) {
throw new Error('ssh_source_receiving_activation_stale')
}
return this.installProvisional(relayPtyId, activation, previous)
}
admit(pending: PendingSshPtySourceData & { source: SshPtySourceFrame }): boolean {
const current = this.deliveryByPty.get(pending.relayPtyId)
if (!acceptsSourceFrame(current, pending.params, pending.source)) {
return false
}
const accepted = Object.freeze({
...current,
sourceEndSu: pending.source.sourceEndSu
}) as SourceDeliveryState
this.deliveryByPty.set(pending.relayPtyId, accepted)
if (accepted.lease.phase === 'recovery') {
accepted.lease.recoverySink?.(pending)
} else if (accepted.lease.phase !== 'committed') {
accepted.lease.pendingData.push(pending)
} else {
this.publishData(pending)
}
return true
}
recordExit(relayPtyId: string): void {
const current = this.deliveryByPty.get(relayPtyId)
if (current?.lease.phase === 'committed') {
this.deliveryByPty.delete(relayPtyId)
} else if (current) {
current.lease.exited = true
}
}
async reject(
relayPtyId: string,
identity: RejectedSourceIdentity | undefined
): Promise<SshPtyRejectedSourceRecovery> {
if (!identity) {
return 'reconnect-channel'
}
const offeredCurrent = this.deliveryByPty.get(relayPtyId)
const matched = Boolean(
offeredCurrent && sameRejectedSourceIdentity(offeredCurrent.activation, identity)
)
const canceled = await settleExactSourceDeliveryCancellation(this.mux, relayPtyId, identity)
if (matched && canceled) {
const current = this.deliveryByPty.get(relayPtyId)
if (current && sameRejectedSourceIdentity(current.activation, identity)) {
this.retire(relayPtyId, current.previous, current.lease)
return 'fresh-activation'
}
return current ? 'reconnect-channel' : 'fresh-activation'
}
if (!matched && !canceled) {
return 'confirm-existing'
}
return 'reconnect-channel'
}
private installProvisional(
relayPtyId: string,
activation: PtySourceReceivingActivation,
previous: SourceDeliveryState | undefined
): SshPtySourceDeliveryLease {
const leaseState: SourceDeliveryLeaseState = {
phase: 'provisional',
pendingData: [],
exited: false
}
this.deliveryByPty.set(
relayPtyId,
Object.freeze({
activation,
sourceEndSu: activation.checkpointSourceEndSu,
lease: leaseState,
...(previous ? { previous } : {})
})
)
let settled = false
let transferInProgress = false
let rollbackSettlement: Promise<boolean> | undefined
return Object.freeze({
commit: () => {
if (settled || transferInProgress) {
return
}
settled = true
this.commit(relayPtyId, leaseState)
},
rollback: () => {
if (rollbackSettlement) {
return rollbackSettlement
}
if (settled || transferInProgress) {
return Promise.resolve(false)
}
settled = true
this.retire(relayPtyId, previous, leaseState)
rollbackSettlement = settleExactSourceDeliveryCancellation(this.mux, relayPtyId, activation)
return rollbackSettlement
},
transferToRecovery: (sink) => {
if (settled || transferInProgress || leaseState.phase !== 'provisional') {
throw new Error('ssh_source_receiving_activation_stale')
}
transferInProgress = true
try {
const recoveryLease = this.transferToRecovery(relayPtyId, leaseState, previous, sink)
settled = true
return recoveryLease
} finally {
transferInProgress = false
}
}
})
}
private transferToRecovery(
relayPtyId: string,
lease: SourceDeliveryLeaseState,
previous: SourceDeliveryState | undefined,
sink: (pending: PendingSshPtySourceData) => void
): SshPtyRecoveryActivationLease {
if (this.deliveryByPty.get(relayPtyId)?.lease !== lease) {
this.retire(relayPtyId, previous, lease)
throw new Error('ssh_source_receiving_activation_stale')
}
lease.phase = 'recovery'
lease.recoverySink = sink
try {
while (lease.pendingData.length > 0) {
sink(lease.pendingData.shift()!)
}
} catch (error) {
this.retire(relayPtyId, previous, lease)
throw error
}
let settled = false
return Object.freeze({
commit: () => {
if (settled) {
return
}
settled = true
lease.recoverySink = undefined
this.commit(relayPtyId, lease)
},
retire: () => {
if (settled) {
return
}
settled = true
lease.recoverySink = undefined
this.retire(relayPtyId, previous, lease)
}
})
}
private commit(relayPtyId: string, lease: SourceDeliveryLeaseState): void {
if (this.deliveryByPty.get(relayPtyId)?.lease !== lease) {
lease.phase = 'retired'
lease.pendingData.splice(0)
return
}
lease.phase = 'committing'
while (lease.pendingData.length > 0) {
this.publishData(lease.pendingData.shift()!)
}
lease.phase = 'committed'
const current = this.deliveryByPty.get(relayPtyId)
if (lease.exited && current?.lease === lease) {
this.deliveryByPty.delete(relayPtyId)
return
}
if (current?.lease === lease && current.previous) {
this.deliveryByPty.set(
relayPtyId,
Object.freeze({
activation: current.activation,
sourceEndSu: current.sourceEndSu,
lease: current.lease
})
)
}
}
private retire(
relayPtyId: string,
previous: SourceDeliveryState | undefined,
lease: SourceDeliveryLeaseState
): void {
lease.phase = 'retired'
lease.recoverySink = undefined
lease.pendingData.splice(0)
if (this.deliveryByPty.get(relayPtyId)?.lease !== lease) {
return
}
if (lease.exited) {
this.deliveryByPty.delete(relayPtyId)
return
}
const predecessor = activePredecessor(previous)
if (predecessor) {
this.deliveryByPty.set(relayPtyId, predecessor)
} else {
this.deliveryByPty.delete(relayPtyId)
}
}
}
@@ -1,134 +0,0 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation'
import type { SshPtySourceFrame } from './ssh-pty-source-frame'
export type PendingSshPtySourceData = Readonly<{
relayPtyId: string
params: Record<string, unknown>
data: string
source?: SshPtySourceFrame
}>
export type SourceDeliveryLeaseState = {
phase: 'provisional' | 'recovery' | 'committing' | 'committed' | 'retired'
pendingData: PendingSshPtySourceData[]
recoverySink?: (pending: PendingSshPtySourceData) => void
exited: boolean
}
export type SourceDeliveryState = Readonly<{
activation: PtySourceReceivingActivation
sourceEndSu: number
lease: SourceDeliveryLeaseState
previous?: SourceDeliveryState
}>
export type SshPtyRecoveryActivationLease = Readonly<{
commit: () => void
retire: () => void
}>
export type SshPtySourceDeliveryLease = Readonly<{
commit: () => void
rollback: () => Promise<boolean>
transferToRecovery: (
sink: (pending: PendingSshPtySourceData) => void
) => SshPtyRecoveryActivationLease
}>
export type SshPtyRejectedSourceRecovery =
| 'confirm-existing'
| 'fresh-activation'
| 'reconnect-channel'
export type RejectedSourceIdentity = Readonly<{
clientGeneration: number
ownerGeneration: number
ptyIncarnation: string
deliveryToken: string
}>
export function settledReceivingActivationLease(): SshPtySourceDeliveryLease {
return Object.freeze({
commit: () => {},
rollback: async () => true,
transferToRecovery: () => Object.freeze({ commit: () => {}, retire: () => {} })
})
}
export function activePredecessor(previous?: SourceDeliveryState): SourceDeliveryState | undefined {
while (previous?.lease.phase === 'retired') {
previous = previous.previous
}
return previous
}
export function sameReceivingActivation(
left: PtySourceReceivingActivation,
right: PtySourceReceivingActivation
): boolean {
return (
left.clientGeneration === right.clientGeneration &&
left.ownerGeneration === right.ownerGeneration &&
left.ptyIncarnation === right.ptyIncarnation &&
left.deliveryToken === right.deliveryToken &&
left.checkpointSourceEndSu === right.checkpointSourceEndSu &&
left.recoveryEndSu === right.recoveryEndSu
)
}
export function sameRejectedSourceIdentity(
left: PtySourceReceivingActivation,
right: RejectedSourceIdentity
): boolean {
return (
left.clientGeneration === right.clientGeneration &&
left.ownerGeneration === right.ownerGeneration &&
left.ptyIncarnation === right.ptyIncarnation &&
left.deliveryToken === right.deliveryToken
)
}
export function acceptsSourceFrame(
current: SourceDeliveryState | undefined,
params: Record<string, unknown>,
source: SshPtySourceFrame
): current is SourceDeliveryState {
return Boolean(
current &&
current.lease.phase !== 'retired' &&
!current.lease.exited &&
current.activation.ptyIncarnation === params.ptyIncarnation &&
current.activation.deliveryToken === source.deliveryToken &&
current.activation.clientGeneration === source.clientGeneration &&
current.activation.ownerGeneration === source.ownerGeneration &&
current.sourceEndSu === source.sourceStartSu
)
}
export async function settleExactSourceDeliveryCancellation(
mux: SshChannelMultiplexer,
relayPtyId: string,
activation: RejectedSourceIdentity
): Promise<boolean> {
try {
const result = (await mux.request('pty.cancelDelivery', {
id: relayPtyId,
clientGeneration: activation.clientGeneration,
ownerGeneration: activation.ownerGeneration,
deliveryToken: activation.deliveryToken
})) as Record<string, unknown>
return (
result.canceled === true &&
nonNegativeSafeInteger(result.sentEndSu) &&
nonNegativeSafeInteger(result.creditedEndSu) &&
result.creditedEndSu <= result.sentEndSu
)
} catch {
return false
}
}
function nonNegativeSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0
}
@@ -1,64 +0,0 @@
import type { SshPtyDataCallback } from './ssh-pty-provider-contract'
export type SshPtySourceFrame = NonNullable<Parameters<SshPtyDataCallback>[0]['source']>
const SOURCE_KEYS = [
'deliveryToken',
'clientGeneration',
'ownerGeneration',
'sourceEndSu',
'sourceLengthSu',
'ptyIncarnation'
] as const
export function parseSshPtySourceFrame(
params: Record<string, unknown>,
data: string,
relayPtyId: string
): Readonly<{ source?: SshPtySourceFrame; malformed: boolean }> {
if (!SOURCE_KEYS.some((key) => params[key] !== undefined)) {
return Object.freeze({ malformed: false })
}
const sourceEndSu = params.sourceEndSu
const sourceLengthSu = params.sourceLengthSu
const rawLength = params.rawLength
const transformed = params.transformed === true
if (
typeof params.data !== 'string' ||
typeof params.deliveryToken !== 'string' ||
params.deliveryToken.length === 0 ||
!positiveSafeInteger(params.clientGeneration) ||
!positiveSafeInteger(params.ownerGeneration) ||
!nonNegativeSafeInteger(sourceEndSu) ||
!nonNegativeSafeInteger(sourceLengthSu) ||
sourceEndSu < sourceLengthSu ||
typeof params.ptyIncarnation !== 'string' ||
params.ptyIncarnation.length === 0 ||
(params.seq !== undefined && !nonNegativeSafeInteger(params.seq)) ||
(transformed ? rawLength !== sourceLengthSu : data.length !== sourceLengthSu) ||
(rawLength !== undefined && rawLength !== sourceLengthSu)
) {
return Object.freeze({ malformed: true })
}
const sourceStartSu = sourceEndSu - sourceLengthSu
return Object.freeze({
malformed: false,
source: Object.freeze({
relayPtyId,
spanId: `${params.deliveryToken}:${sourceStartSu}:${sourceEndSu}`,
clientGeneration: params.clientGeneration,
ownerGeneration: params.ownerGeneration,
deliveryToken: params.deliveryToken,
sourceStartSu,
sourceEndSu
})
})
}
function positiveSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) > 0
}
function nonNegativeSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0
}
@@ -1,66 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env'
import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract'
describe('buildSshPtySpawnEnv relay bridge', () => {
it('prepends the CLI bin dir once and publishes the Node relay bridge', () => {
const env = buildSshPtySpawnEnv({
env: { PATH: '/home/me/.orca-relay/bin:/usr/bin' },
remoteCliBridgeEnv: {
binDir: '/home/me/.orca-relay/bin',
relayDir: '/home/me/.orca-relay/relay-v1',
nodePath: '/usr/bin/node',
sockPath: '/home/me/.orca-relay/relay.sock'
}
})
expect(env).toMatchObject({
PATH: '/home/me/.orca-relay/bin:/usr/bin',
ORCA_REMOTE_CLI_BIN_DIR: '/home/me/.orca-relay/bin',
ORCA_RELAY_DIR: '/home/me/.orca-relay/relay-v1',
ORCA_RELAY_NODE_PATH: '/usr/bin/node',
ORCA_RELAY_SOCKET_PATH: '/home/me/.orca-relay/relay.sock'
})
expect(env).not.toHaveProperty('ORCA_RELAY_CREDENTIAL_FILE')
})
})
const bridge: RemoteCliBridgeEnv = {
binDir: '/remote/orca/bin',
relayDir: '/remote/orca',
nodePath: '/remote/node',
sockPath: '/remote/orca/socket'
}
describe('SSH CLI path ownership', () => {
it('replaces the client restore directory with the remote bridge', () => {
const env = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/client/orca/bin' }
const result = buildSshPtySpawnEnv({ env, remoteCliBridgeEnv: bridge })
expect(result.ORCA_CLI_BIN_DIR).toBe(bridge.binDir)
expect(result.PATH).toBe(`${bridge.binDir}:/usr/bin`)
expect(env.ORCA_CLI_BIN_DIR).toBe('/client/orca/bin')
})
it('clears a client path when no remote bridge is available', () => {
const result = buildSshPtySpawnEnv({ env: { ORCA_CLI_BIN_DIR: '/client/orca/bin' } })
expect(result.ORCA_CLI_BIN_DIR).toBeUndefined()
})
it('does not give a POSIX wrapper a Windows bridge directory', () => {
const result = buildSshPtySpawnEnv({
env: { Path: 'C:\\Windows', ORCA_CLI_BIN_DIR: '/client/orca/bin' },
remoteCliBridgeEnv: { ...bridge, binDir: 'C:\\Orca\\bin', pathDelimiter: ';' }
})
expect(result.ORCA_CLI_BIN_DIR).toBeUndefined()
expect(result.Path).toBe('C:\\Orca\\bin;C:\\Windows')
})
it('preserves an explicitly deleted restore key', () => {
const result = buildSshPtySpawnEnv({
env: { PATH: '/usr/bin' },
remoteCliBridgeEnv: bridge,
envToDelete: ['ORCA_CLI_BIN_DIR']
})
expect(result.ORCA_CLI_BIN_DIR).toBeUndefined()
})
})
-40
View File
@@ -1,40 +0,0 @@
import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env'
import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract'
export function buildSshPtySpawnEnv(args: {
env: Record<string, string> | undefined
envToDelete?: readonly string[]
remoteCliBridgeEnv?: RemoteCliBridgeEnv
}): Record<string, string> {
const merged = { ...args.env }
// The client CLI path cannot be restored on the execution host.
delete merged.ORCA_CLI_BIN_DIR
if (args.remoteCliBridgeEnv) {
const pathDelimiter = args.remoteCliBridgeEnv.pathDelimiter ?? ':'
if (pathDelimiter === ':') {
merged.ORCA_CLI_BIN_DIR = args.remoteCliBridgeEnv.binDir
}
const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null
if (pathKey) {
const pathValue = merged[pathKey] ?? ''
merged[pathKey] = pathValue.split(pathDelimiter).includes(args.remoteCliBridgeEnv.binDir)
? pathValue
: pathValue
? `${args.remoteCliBridgeEnv.binDir}${pathDelimiter}${pathValue}`
: args.remoteCliBridgeEnv.binDir
}
merged.ORCA_REMOTE_CLI_BIN_DIR = args.remoteCliBridgeEnv.binDir
merged.ORCA_RELAY_DIR = args.remoteCliBridgeEnv.relayDir
merged.ORCA_RELAY_NODE_PATH = args.remoteCliBridgeEnv.nodePath
merged.ORCA_RELAY_SOCKET_PATH = args.remoteCliBridgeEnv.sockPath
if (args.remoteCliBridgeEnv.credentialFile) {
merged.ORCA_RELAY_CREDENTIAL_FILE = args.remoteCliBridgeEnv.credentialFile
}
}
// Why: match local/daemon precedence—managed defaults cannot restore explicitly removed values.
for (const key of args.envToDelete ?? []) {
delete merged[key]
}
seedPowerlevel10kWizardEnv(merged, { envToDelete: args.envToDelete })
return merged
}
@@ -1,41 +0,0 @@
import { isPtyIncarnationId, type PtyIncarnationId } from '../../shared/pty-incarnation'
type PendingSshPtySpawn = {
exits: { relayPtyId: string; incarnationId?: PtyIncarnationId }[]
}
export class SshPtySpawnExitRaceTracker {
private pending = new Set<PendingSshPtySpawn>()
begin(): PendingSshPtySpawn {
const operation = { exits: [] }
this.pending.add(operation)
return operation
}
recordExit(relayPtyId: string, incarnationId: unknown): void {
for (const operation of this.pending) {
operation.exits.push({
relayPtyId,
...(isPtyIncarnationId(incarnationId) ? { incarnationId } : {})
})
}
}
didMatchingExitArrive(
operation: PendingSshPtySpawn,
result: { id: string; incarnationId?: PtyIncarnationId }
): boolean {
return operation.exits.some(
(exit) =>
exit.relayPtyId === result.id &&
(!exit.incarnationId ||
!result.incarnationId ||
exit.incarnationId === result.incarnationId)
)
}
finish(operation: PendingSshPtySpawn): void {
this.pending.delete(operation)
}
}
@@ -1,45 +0,0 @@
/**
* The client seam for #17830: a spawn the relay refused because it cannot load node-pty.
*
* Split out of ssh-pty-provider.ts so the provider keeps only the wiring. The repair itself is
* driven by SshRelaySession, which owns the connection, the repair lock and the reconnect.
*/
import {
mayRepairFromCause,
terminalUnavailableCauseFromError,
type TerminalUnavailableCause
} from '../../shared/terminal-unavailable-cause'
/** Resolves to the provider registered after a successful repair, or null to keep the rejection. */
export type TerminalRepairHook<TProvider> = (
cause: TerminalUnavailableCause
) => Promise<TProvider | null>
/**
* Run a spawn, and on a proved-repairable terminal-unavailable rejection repair the host and
* retry exactly once on the provider the repair produced.
*
* Re-issuing is safe because a validated cause is the host's own statement that admission was
* refused before any PTY existed, so there is nothing to duplicate. The retry deliberately goes
* through a caller-supplied thunk that does not re-enter this wrapper, so it cannot recurse.
* Gated on `mayRepairFromCause`, never on the peer's `repairable` flag alone.
*/
export async function spawnWithTerminalRuntimeRepair<TProvider, TResult>(args: {
attempt: () => Promise<TResult>
recover: TerminalRepairHook<TProvider> | null
retry: (provider: TProvider) => Promise<TResult>
}): Promise<TResult> {
try {
return await args.attempt()
} catch (error) {
const cause = terminalUnavailableCauseFromError(error)
if (!cause || !mayRepairFromCause(cause) || !args.recover) {
throw error
}
const repaired = await args.recover(cause)
if (!repaired) {
throw error
}
return await args.retry(repaired)
}
}
@@ -1,52 +0,0 @@
import type { PtySpawnOptions } from './types'
import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract'
import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env'
import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress'
export function buildSshPtySpawnRequest(args: {
options: PtySpawnOptions
remoteCliBridgeEnv?: RemoteCliBridgeEnv
supportsCreateOperation: boolean
}): Record<string, unknown> {
const { options } = args
return {
cols: options.cols,
rows: options.rows,
cwd: options.cwd,
env: buildSshPtySpawnEnv({
env: options.env,
envToDelete: options.envToDelete,
remoteCliBridgeEnv: args.remoteCliBridgeEnv
}),
...(options.envToDelete?.length ? { envToDelete: options.envToDelete } : {}),
// Why: the relay needs launch identity for plugin env overlays and provider-side delivery.
...(options.command ? { command: options.command } : {}),
...(options.launchAgent ? { launchAgent: options.launchAgent } : {}),
...(options.worktreeId ? { worktreeId: options.worktreeId } : {}),
...(options.historyIsolationEnabled !== undefined
? { historyIsolationEnabled: options.historyIsolationEnabled }
: {}),
...(options.agentWorkspaceTrust ? { agentWorkspaceTrust: options.agentWorkspaceTrust } : {}),
...(options.shellOverride !== undefined ? { shellOverride: options.shellOverride } : {}),
...(options.terminalWindowsWslDistro !== undefined
? { terminalWindowsWslDistro: options.terminalWindowsWslDistro }
: {}),
...(options.commandDelivery ? { commandDelivery: options.commandDelivery } : {}),
...(options.startupCommandDelivery
? { startupCommandDelivery: options.startupCommandDelivery }
: {}),
// Why: attach identity must survive even when hook variables are stripped from the shell env.
...(options.paneKey ? { paneKey: options.paneKey } : {}),
...(options.tabId ? { tabId: options.tabId } : {}),
...(options.startupIngress
? {
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
startupIngress: options.startupIngress
}
: {}),
...(options.agentSessionEnsure ? { agentSessionEnsure: options.agentSessionEnsure } : {}),
...(args.supportsCreateOperation
? { agentSessionCreateOperationId: options.agentSessionCreateOperationId }
: {})
}
}

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