diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx index 3902bd03145..158040ff485 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx @@ -189,6 +189,7 @@ export function TerminalPaneRecoveryPortals({ if (!recoveryState || recoveryState.reason === 'ownership') { return null } + const awaitingSignIn = recoveryState.reason === 'ssh-sign-in' return createPortal( { + if (response.ok) { + retry() + } + }) + .catch(() => undefined) + return + } retry() }} wslHostUnavailable={wslHostUnavailable} pairingRefused={pairingRefusedEnvironmentId !== null} + awaitingSignIn={awaitingSignIn} />, pane.container, `remote-runtime-reconnect-${pane.id}` diff --git a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx index b4a652b7795..3065f0ca677 100644 --- a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx @@ -57,6 +57,29 @@ describe('TerminalRemoteRuntimeReconnectBanner', () => { }) }) +describe('SSH sign-in wait', () => { + it.each(['backoff', 'disconnected'] as const)( + 'shows the sign-in wait at once during %s, never the runtime-lost copy', + async (phase) => { + const onReconnect = vi.fn() + const user = userEvent.setup() + render( + + ) + + expect(screen.getByText('Waiting for SSH sign-in')).toBeInTheDocument() + expect(screen.queryByText(/Remote runtime disconnected|Reconnecting/)).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(onReconnect).toHaveBeenCalledOnce() + } + ) +}) + describe('DelayedRemoteRuntimeReconnectBanner', () => { it('keeps a brief resubscribe from flashing the retrying banner', () => { vi.useFakeTimers() diff --git a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx index fe3760342bd..bd0c9502187 100644 --- a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Loader2, ServerOff } from 'lucide-react' +import { KeyRound, Loader2, ServerOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import type { PtyTransportRecoveryState } from './pty-transport-types' @@ -38,7 +38,8 @@ export function TerminalRemoteRuntimeReconnectBanner({ phase, onReconnect, wslHostUnavailable = null, - pairingRefused = false + pairingRefused = false, + awaitingSignIn = false }: { phase: VisibleRecoveryPhase onReconnect: () => void @@ -46,16 +47,77 @@ export function TerminalRemoteRuntimeReconnectBanner({ wslHostUnavailable?: WslHostUnavailableReason | null /** The pane's managed server refuses this desktop and pairing again over SSH failed. */ pairingRefused?: boolean + /** Main refused because the SSH host is waiting for the user to sign in; it resumes after. */ + awaitingSignIn?: boolean }): React.JSX.Element { if (wslHostUnavailable) { return } - const retrying = phase !== 'disconnected' && !pairingRefused + const signingIn = awaitingSignIn && !pairingRefused + const retrying = phase !== 'disconnected' && !pairingRefused && !signingIn + const reconnectLabel = translate( + 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.reconnectButton', + 'Reconnect' + ) + const copy = pairingRefused + ? { + bannerState: 'pairing-refused', + title: translate( + 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.pairingRefusedTitle', + 'This host no longer accepts this computer' + ), + body: translate( + 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.pairingRefusedBody', + "Orca couldn't pair with it again over SSH. Nothing on the host was stopped. Reconnect to try again." + ), + action: reconnectLabel + } + : signingIn + ? { + bannerState: 'ssh-sign-in', + title: translate( + 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.signInTitle', + 'Waiting for SSH sign-in' + ), + body: translate( + 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.signInBody', + 'This terminal continues by itself once you sign in to the host.' + ), + action: translate( + 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.signInButton', + 'Sign in' + ) + } + : retrying + ? { + bannerState: phase, + title: translate( + 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingTitle', + 'Reconnecting to remote runtime' + ), + body: translate( + 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingBody', + 'Orca is retrying automatically. This terminal will resume if the connection returns.' + ), + action: null + } + : { + bannerState: phase, + title: translate( + 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedTitle', + 'Remote runtime disconnected' + ), + body: translate( + 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedBody', + 'Automatic retries stopped. Reconnect to resume this terminal session.' + ), + action: reconnectLabel + } return (
{retrying ? ( + ) : signingIn ? ( + ) : ( )}
-
- {pairingRefused - ? translate( - 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.pairingRefusedTitle', - 'This host no longer accepts this computer' - ) - : retrying - ? translate( - 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingTitle', - 'Reconnecting to remote runtime' - ) - : translate( - 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedTitle', - 'Remote runtime disconnected' - )} -
-
- {pairingRefused - ? translate( - 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.pairingRefusedBody', - "Orca couldn't pair with it again over SSH. Nothing on the host was stopped. Reconnect to try again." - ) - : retrying - ? translate( - 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingBody', - 'Orca is retrying automatically. This terminal will resume if the connection returns.' - ) - : translate( - 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedBody', - 'Automatic retries stopped. Reconnect to resume this terminal session.' - )} -
+
{copy.title}
+
{copy.body}
- {!retrying ? ( + {copy.action ? ( ) : null}
@@ -125,6 +156,7 @@ export function DelayedRemoteRuntimeReconnectBanner({ }): React.JSX.Element | null { const revealed = useRemoteRuntimeRecoveryBannerRevealed( props.phase === 'disconnected' || + Boolean(props.awaitingSignIn) || Boolean(props.wslHostUnavailable) || Boolean(props.pairingRefused) ) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 22e3afbc7d8..88bc9a8b544 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -154,7 +154,7 @@ type PtyCallbacks = { } export type PtyTransportRecoveryState = { - reason?: 'ownership' + reason?: 'ownership' | 'ssh-sign-in' phase: | 'connecting' | 'connected' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index ce6ea995f2b..08a34afeb01 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -342,6 +342,22 @@ export function createRemoteRuntimePtyTransport( const disconnectedInputGrace = createRemoteRuntimeDisconnectedInputGrace() let previousRecoveryPhase: RemoteRuntimePtyRecoveryPhase = 'idle' let unregisterControlReadyReviver: (() => void) | null = null + // Why: the pane's retry is waiting on the user's SSH sign-in, not on a lost link. + let awaitingSshSignIn = false + // Why: the caller's spawn finalization (launch config, startup delivery) must see the terminal + // recovery binds after sign-in, so its connect stays pending until recovery settles either way. + let signInConnectWait: PromiseWithResolvers | null = null + function settleSignInConnectWait(result?: PtyConnectResult): void { + const wait = signInConnectWait + signInConnectWait = null + wait?.resolve(result) + } + function setAwaitingSshSignIn(next: boolean): void { + if (awaitingSshSignIn !== next) { + awaitingSshSignIn = next + emitRecoveryState() + } + } // Why registered only while recovering: detach and destroy go idle, so a discarded transport is never retained. function syncControlReadyReviver(): void { const wantsRevival = recovery.isActive || recovery.currentPhase === 'disconnected' @@ -349,7 +365,14 @@ export function createRemoteRuntimePtyTransport( // Why: a backoff advances and a latched pane replays its attach, exactly as online/resume/Reconnect would. unregisterControlReadyReviver = registerRemoteRuntimePtyControlReadyReviver( runtimeEnvironmentId, - () => !destroyed && (recovery.retryNow() || (transport.retryRecovery?.() ?? false)) + () => { + if (destroyed) { + return false + } + // Why: a ready control socket proves sign-in finished, whatever the replay meets next. + setAwaitingSshSignIn(false) + return recovery.retryNow() || (transport.retryRecovery?.() ?? false) + } ) } else if (!wantsRevival && unregisterControlReadyReviver) { unregisterControlReadyReviver() @@ -384,6 +407,11 @@ export function createRemoteRuntimePtyTransport( subscriptionGeneration += 1 closeMultiplexedStream() } + if (recovery.currentPhase === 'idle' || recovery.currentPhase === 'disposed') { + awaitingSshSignIn = false + // Why: a healthy recovery settled it first with the bound terminal; anything else gave up. + settleSignInConnectWait() + } if (recovery.currentPhase === 'idle') { autoRecoveryWindowSpent = false // Why before the reset: input that outlived the grace must not release into the recovered pane. @@ -615,13 +643,20 @@ export function createRemoteRuntimePtyTransport( : connected && attachmentReady ? 'connected' : 'offline' + const ownership = + (recovery.isActive && recovery.hasOwnerRefresh) || surfacedErrorMessages.has(ownerChanged) + const awaitingSignIn = + awaitingSshSignIn && + (phase === 'recovering' || phase === 'backoff' || phase === 'disconnected') return { phase, epoch: recovery.currentEpoch, attempt: recovery.attemptCount, - ...((recovery.isActive && recovery.hasOwnerRefresh) || surfacedErrorMessages.has(ownerChanged) + ...(ownership ? { reason: 'ownership' as const } - : {}) + : awaitingSignIn + ? { reason: 'ssh-sign-in' as const } + : {}) } } @@ -651,6 +686,20 @@ export function createRemoteRuntimePtyTransport( } function markRecoveryHealthy(result?: PtyConnectResult): void { + if (signInConnectWait) { + settleSignInConnectWait( + remotePtyId + ? { + id: remotePtyId, + replay: '', + ...(spawnedHandle === handle ? {} : { isReattach: true }), + ...(authoritativePtyIncarnationId + ? { incarnationId: authoritativePtyIncarnationId } + : {}) + } + : undefined + ) + } const recoveredErrors = [...surfacedErrorMessages] surfacedErrorMessages.clear() recovery.markHealthy(result) @@ -1043,7 +1092,7 @@ export function createRemoteRuntimePtyTransport( return true } if (lastConnectOptions) { - void connectHoldingRestoredInput(lastConnectOptions) + void connectHoldingRestoredInput(lastConnectOptions, true) return true } return false @@ -1329,6 +1378,7 @@ export function createRemoteRuntimePtyTransport( if (!isRecoverableRemoteRuntimeConnectionError(statusClientError)) { throw statusError } + setAwaitingSshSignIn(false) const startsRecovery = recoveryDeadlineAt === null recoveryDeadlineAt ??= Date.now() + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS if (startsRecovery && !recovery.isActive) { @@ -1373,6 +1423,7 @@ export function createRemoteRuntimePtyTransport( if (!isRecoverableRemoteRuntimeConnectionError(clientError)) { throw error } + setAwaitingSshSignIn(false) if (kind === 'agent-session') { agentSessionRequiresHostAuthorityReplay = true } else { @@ -3236,6 +3287,7 @@ export function createRemoteRuntimePtyTransport( // Why: no handle is bound yet, so the resubscribe path cannot own this retry; replay attach // instead of latching in 'connecting' with nothing armed (#18495). connecting = false + setAwaitingSshSignIn(isSshSignInPendingTerminalError(runtimeTerminalErrorMessage(error))) scheduleConnectRetryAfterRecoverableFailure() if (handle && isRemoteTerminalStaleMessage(runtimeTerminalErrorMessage(error))) { retireIfHostRecordsExit(handle, remotePtyId ?? options.existingPtyId ?? null) @@ -3295,17 +3347,34 @@ export function createRemoteRuntimePtyTransport( // Why: typing on a restored screen reaches its original terminal only, never a fresh create. async function connectHoldingRestoredInput( - options: Parameters[0] + options: Parameters[0], + replay = false ): Promise { + if (!replay) { + settleSignInConnectWait() + } const expected = recoveryInputHold.expect(restoredInputEndpoint(options.sessionId)) + let result: PtyConnectResult | undefined try { - return await connectPane(options) + result = await connectPane(options) } finally { recoveryInputHold.settleExpectation(expected, handle !== null && !destroyed) if (handle && recoveryInputHold.isHolding()) { releaseHeldInput() } } + const retryOwned = !destroyed && (recovery.isActive || recovery.currentPhase === 'disconnected') + if ( + !replay && + result === undefined && + retryOwned && + awaitingSshSignIn && + lastConnectOptions === options + ) { + signInConnectWait ??= Promise.withResolvers() + return signInConnectWait.promise + } + return result } async function connectPane( @@ -3568,7 +3637,13 @@ export function createRemoteRuntimePtyTransport( } else if (isRemoteTerminalGoneMessage(message)) { recovery.cancel() handleRemoteTerminalError(error) + } else if (isSshSignInPendingTerminalError(message)) { + // Why: main refused before dispatch, so nothing was created; the control-ready revival + // replays this create once sign-in finishes (#31942). + setAwaitingSshSignIn(true) + scheduleConnectRetryAfterRecoverableFailure() } else if (isRecoverablePaneBindingError(error)) { + setAwaitingSshSignIn(false) scheduleConnectRetryAfterRecoverableFailure() } else { recovery.cancel() @@ -3587,11 +3662,13 @@ export function createRemoteRuntimePtyTransport( connect: connectHoldingRestoredInput, attach(options) { + settleSignInConnectWait() recoveryInputHold.expect(null) attachPane(options, false) }, disconnect() { + settleSignInConnectWait() lifecycleEpoch += 1 attachGeneration += 1 discardPendingInput() @@ -3630,6 +3707,7 @@ export function createRemoteRuntimePtyTransport( // Why first: the successor transport owns the PTY after detach, and the batcher flushes // below can throw past the census drop — a stranded gauge outlives the transport. outputProcessor.disposePendingSideEffectGauge() + settleSignInConnectWait() lifecycleEpoch += 1 attachGeneration += 1 discardPendingInput() @@ -3793,7 +3871,7 @@ export function createRemoteRuntimePtyTransport( recovery.currentPhase === 'disconnected' ) { recovery.begin() - void connectHoldingRestoredInput(lastConnectOptions) + void connectHoldingRestoredInput(lastConnectOptions, true) return true } // Why: online/resume fires a parked retry; the button must not be weaker than an event (#12684). diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-ssh-sign-in-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-ssh-sign-in-recovery.test.ts index 014af4fa26f..13bd1ebeebe 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-ssh-sign-in-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-ssh-sign-in-recovery.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { PtyTransport } from './pty-transport-types' +import type { PtyTransport, PtyTransportRecoveryState } from './pty-transport-types' import { createRemoteRuntimeTransportMocks, readyHostSessionInventoryResponse, @@ -21,6 +21,7 @@ let transport: PtyTransport | undefined const { runtimeCall, + runtimeSubscribe, latestSubscribePayload, subscribedTerminalHandles, emitSnapshot, @@ -120,7 +121,7 @@ describe.each([ ) )('restores original-terminal I/O for $name $timing', async (failure) => { const { pane, onData, onError, signIn } = await mountAwaitingPane(tabId, failure) - expect(pane.getRecoveryState?.().phase).toBe('backoff') + expect(pane.getRecoveryState?.()).toMatchObject({ phase: 'backoff', reason: 'ssh-sign-in' }) expect(subscribedTerminalHandles()).toEqual([]) if (failure.late) { @@ -183,3 +184,201 @@ describe.each([ expect(runtimeCall).toHaveBeenCalledTimes(callsAfterTeardown) }) }) + +async function mountNewPaneAwaitingSignIn(failure: { message: string }, afterSignIn?: () => void) { + let signedIn = false + const healthyCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (!signedIn) { + throw new Error(failure.message) + } + afterSignIn?.() + return healthyCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { reviveRemoteRuntimePtyRecoveriesForEnvironment } = + await import('./remote-runtime-pty-recovery-state') + const onError = vi.fn() + const onPtySpawn = vi.fn() + const publishedStates: PtyTransportRecoveryState[] = [] + const pane = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-new', + leafId: 'pane:1', + onPtySpawn + }) + transport = pane + let connectSettled = false + const connected = Promise.resolve( + pane.connect({ + url: '', + cols: 80, + rows: 24, + callbacks: { onError, onRecoveryStateChange: (state) => publishedStates.push(state) } + }) + ).finally(() => { + connectSettled = true + }) + await vi.advanceTimersByTimeAsync(0) + return { + connected, + connectSettled: () => connectSettled, + pane, + lastPublishedState: () => publishedStates.at(-1), + onError, + onPtySpawn, + createCalls: () => + runtimeCall.mock.calls.filter(([request]) => request.method === 'terminal.create'), + signIn: () => { + signedIn = true + return reviveRemoteRuntimePtyRecoveriesForEnvironment('env-1') + } + } +} + +describe('SSH sign-in for a terminal created while the host waits for sign-in', () => { + it.each( + pendingErrors.flatMap((failure) => + [false, true].map((late) => ({ + ...failure, + late, + timing: late ? 'after the recovery deadline' : 'before the recovery deadline' + })) + ) + )('waits for sign-in and then starts for $name $timing', async (failure) => { + const { pane, onError, onPtySpawn, signIn, connected, connectSettled } = + await mountNewPaneAwaitingSignIn(failure) + expect(pane.getRecoveryState?.()).toMatchObject({ + phase: 'backoff', + reason: 'ssh-sign-in' + }) + expect(onError).not.toHaveBeenCalled() + expect(connectSettled()).toBe(false) + + if (failure.late) { + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1) + expect(pane.getRecoveryState?.()).toMatchObject({ + phase: 'disconnected', + reason: 'ssh-sign-in' + }) + } + + expect(signIn()).toBe(1) + await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) + const { streamId } = latestSubscribePayload() + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'subscribed', streamId } + }) + emitSnapshot(streamId, 'fresh prompt$ ') + + expect(onPtySpawn).toHaveBeenCalledTimes(1) + expect(onPtySpawn).toHaveBeenCalledWith('remote:env-1@@terminal-1') + // The original spawn settles with the post-sign-in create, so its finalization still runs. + await expect(connected).resolves.toMatchObject({ id: 'remote:env-1@@terminal-1' }) + expect(pane.isConnected()).toBe(true) + expect(pane.getRecoveryState?.().reason).toBeUndefined() + expect(onError).not.toHaveBeenCalled() + }) + + it('starts after a sign-in that was cancelled and completed later', async () => { + const failure = { message: pendingErrors[0].message } + const pending = await mountNewPaneAwaitingSignIn(failure) + failure.message = pendingErrors[2].message + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1) + expect(pending.pane.getRecoveryState?.()).toMatchObject({ + phase: 'disconnected', + reason: 'ssh-sign-in' + }) + + expect(pending.signIn()).toBe(1) + await vi.waitFor(() => expect(pending.onPtySpawn).toHaveBeenCalledTimes(1)) + expect(pending.onError).not.toHaveBeenCalled() + }) + + it('settles the original spawn when the stream, not the create, waited for sign-in', async () => { + resolvedPaneHandle = 'terminal-1' + let signedIn = false + const healthySubscribe = runtimeSubscribe.getMockImplementation() + runtimeSubscribe.mockImplementation(async (...args: unknown[]) => { + if (!signedIn) { + throw new Error(pendingErrors[0].message) + } + return healthySubscribe?.(...args) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { reviveRemoteRuntimePtyRecoveriesForEnvironment } = + await import('./remote-runtime-pty-recovery-state') + const onPtySpawn = vi.fn() + const pane = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-new', + leafId: 'pane:1', + onPtySpawn + }) + transport = pane + const connected = Promise.resolve(pane.connect({ url: '', cols: 80, rows: 24, callbacks: {} })) + await vi.advanceTimersByTimeAsync(0) + expect(pane.getRecoveryState?.().reason).toBe('ssh-sign-in') + + signedIn = true + expect(reviveRemoteRuntimePtyRecoveriesForEnvironment('env-1')).toBe(1) + await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) + const { streamId } = latestSubscribePayload() + subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'subscribed', streamId } }) + emitSnapshot(streamId, 'fresh prompt$ ') + + await expect(connected).resolves.toMatchObject({ id: 'remote:env-1@@terminal-1' }) + expect(onPtySpawn).toHaveBeenCalledTimes(1) + }) + + it('stops showing the sign-in wait once sign-in finishes, even while the create still retries', async () => { + const { pane, lastPublishedState, signIn } = await mountNewPaneAwaitingSignIn( + pendingErrors[0], + () => { + throw Object.assign(new Error('Could not connect to the remote Orca runtime.'), { + code: 'remote_runtime_unavailable' + }) + } + ) + expect(lastPublishedState()?.reason).toBe('ssh-sign-in') + + signIn() + await vi.advanceTimersByTimeAsync(0) + + expect(pane.getRecoveryState?.().phase).not.toBe('idle') + expect(lastPublishedState()?.reason).toBeUndefined() + }) + + it.each(['destroy', 'disconnect'] as const)( + 'spawns nothing after sign-in once the pending terminal is closed (%s)', + async (teardown) => { + const { pane, onPtySpawn, signIn, createCalls, connected } = await mountNewPaneAwaitingSignIn( + pendingErrors[0] + ) + const createsBeforeClose = createCalls().length + pane[teardown]?.() + await expect(connected).resolves.toBeUndefined() + + expect(signIn()).toBe(0) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1) + + expect(createCalls()).toHaveLength(createsBeforeClose) + expect(onPtySpawn).not.toHaveBeenCalled() + expect(subscribedTerminalHandles()).toEqual([]) + } + ) + + it('still reports rejected credentials instead of waiting', async () => { + const { pane, onError, signIn, createCalls } = await mountNewPaneAwaitingSignIn({ + message: 'All configured authentication methods failed' + }) + expect(onError).toHaveBeenCalledWith('All configured authentication methods failed') + expect(pane.getRecoveryState?.().reason).toBeUndefined() + const createsAfterFailure = createCalls().length + + expect(signIn()).toBe(0) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1) + expect(createCalls()).toHaveLength(createsAfterFailure) + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ff31398abb3..a79e1bef9e3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3829,7 +3829,10 @@ "disconnectedTitle": "Remote runtime disconnected", "retryingBody": "Orca is retrying automatically. This terminal will resume if the connection returns.", "disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.", - "reconnectButton": "Reconnect" + "reconnectButton": "Reconnect", + "signInTitle": "Waiting for SSH sign-in", + "signInBody": "This terminal continues by itself once you sign in to the host.", + "signInButton": "Sign in" }, "TerminalQuickCommandsSubmenu": { "3ccc7981bb": "Host unavailable", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9faf673cc29..3afd4c23074 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3526,7 +3526,10 @@ "disconnectedTitle": "Remote runtime disconnected", "retryingBody": "Orca está reintentando automáticamente. Este terminal se reanudará si vuelve la conexión.", "disconnectedBody": "Se detuvieron los reintentos automáticos. Vuelva a conectarse para reanudar esta sesión de terminal.", - "reconnectButton": "Reconnect" + "reconnectButton": "Reconnect", + "signInTitle": "Esperando el inicio de sesión SSH", + "signInBody": "Este terminal continuará por sí solo cuando inicie sesión en el host.", + "signInButton": "Iniciar sesión" }, "TerminalRestoringPlaceholder": { "restoring": "Restaurando terminal…" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index fabdde1c3ec..9c78c89d314 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3695,7 +3695,10 @@ "disconnectedTitle": "Runtime distant déconnecté", "retryingBody": "Orca réessaie automatiquement. Ce terminal reprendra si la connexion revient.", "disconnectedBody": "Les tentatives automatiques sont interrompues. Reconnectez-vous pour reprendre cette session de terminal.", - "reconnectButton": "Reconnecter" + "reconnectButton": "Reconnecter", + "signInTitle": "En attente de la connexion SSH", + "signInBody": "Ce terminal reprend tout seul dès que vous vous connectez à l’hôte.", + "signInButton": "Se connecter" }, "TerminalQuickCommandsSubmenu": { "3ccc7981bb": "Hôte indisponible", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index addd4f6cc11..4f35ee4a440 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3511,7 +3511,10 @@ "disconnectedTitle": "リモートランタイムが切断されました", "retryingBody": "Orca は自動的に再試行します。接続が復元されると、このターミナルが再開されます。", "disconnectedBody": "自動再試行が停止しました。このターミナルセッションを再開するには再接続してください。", - "reconnectButton": "再接続" + "reconnectButton": "再接続", + "signInTitle": "SSH のサインインを待っています", + "signInBody": "ホストにサインインすると、このターミナルは自動的に続行します。", + "signInButton": "サインイン" }, "TerminalLinkActionPopover": { "openLink": "リンクを開く", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index fd3f76dfcc4..33b04edc432 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3511,7 +3511,10 @@ "disconnectedTitle": "원격 런타임 연결 끊김", "retryingBody": "Orca가 자동으로 재시도합니다. 연결이 복원되면 이 터미널이 재개됩니다.", "disconnectedBody": "자동 재시도가 중지되었습니다. 이 터미널 세션을 재개하려면 다시 연결하세요.", - "reconnectButton": "다시 연결" + "reconnectButton": "다시 연결", + "signInTitle": "SSH 로그인 대기 중", + "signInBody": "호스트에 로그인하면 이 터미널이 자동으로 계속됩니다.", + "signInButton": "로그인" }, "TerminalLinkActionPopover": { "openLink": "링크 열기", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 7bfa5473cb8..2a20accbb9c 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -15652,7 +15652,10 @@ "disconnectedBody": "As tentativas automáticas foram interrompidas. Reconecte para retomar esta sessão de terminal.", "reconnectButton": "Reconectar", "pairingRefusedTitle": "Este host não aceita mais este computador", - "pairingRefusedBody": "O Orca não conseguiu parear com ele novamente via SSH. Nada no host foi interrompido. Reconecte para tentar novamente." + "pairingRefusedBody": "O Orca não conseguiu parear com ele novamente via SSH. Nada no host foi interrompido. Reconecte para tentar novamente.", + "signInTitle": "Aguardando login SSH", + "signInBody": "Este terminal continua sozinho quando você entrar no host.", + "signInButton": "Entrar" }, "TerminalPane": { "ac112e9036": "Remover título", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1f043c1d872..3af40d5080e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3511,7 +3511,10 @@ "disconnectedTitle": "远程运行时已断开连接", "retryingBody": "Orca 正在自动重试。如果连接恢复,此终端将恢复。", "disconnectedBody": "自动重试已停止。重新连接以恢复此终端会话。", - "reconnectButton": "重新连接" + "reconnectButton": "重新连接", + "signInTitle": "正在等待 SSH 登录", + "signInBody": "登录到主机后,此终端会自动继续。", + "signInButton": "登录" }, "TerminalLinkActionPopover": { "openLink": "打开链接",