From c3bfc796af17fd2af3d9d824acbeb8606ec492f7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:22 -0400 Subject: [PATCH] Fix SSH relay persistence and reset controls (#2274) Co-authored-by: Orca --- src/main/ipc/ssh.test.ts | 213 +++++++++++++++++ src/main/ipc/ssh.ts | 97 +++++++- src/main/ssh/relay-protocol.ts | 2 +- src/main/ssh/ssh-relay-deploy.test.ts | 39 ++++ src/main/ssh/ssh-relay-deploy.ts | 25 +- src/main/ssh/ssh-relay-instance-id.ts | 9 + src/main/ssh/ssh-relay-reset.test.ts | 25 ++ src/main/ssh/ssh-relay-reset.ts | 37 +++ src/preload/api-types.ts | 1 + src/preload/index.ts | 3 + src/relay/pty-handler.test.ts | 14 +- src/relay/pty-handler.ts | 3 +- src/relay/relay.ts | 3 +- .../settings/SshDestructiveActionDialog.tsx | 69 ++++++ .../src/components/settings/SshPane.tsx | 218 +++++++----------- .../src/components/settings/SshTargetCard.tsx | 83 +++++-- .../settings/SshTargetDestructiveActions.tsx | 205 ++++++++++++++++ .../src/components/settings/SshTargetForm.tsx | 20 +- .../settings/ssh-target-action-state.ts | 13 ++ src/renderer/src/web/web-preload-api.ts | 1 + src/shared/ssh-types.ts | 11 +- 21 files changed, 902 insertions(+), 189 deletions(-) create mode 100644 src/main/ssh/ssh-relay-instance-id.ts create mode 100644 src/main/ssh/ssh-relay-reset.test.ts create mode 100644 src/main/ssh/ssh-relay-reset.ts create mode 100644 src/renderer/src/components/settings/SshDestructiveActionDialog.tsx create mode 100644 src/renderer/src/components/settings/SshTargetDestructiveActions.tsx create mode 100644 src/renderer/src/components/settings/ssh-target-action-state.ts diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index ad2c243fc..30472f9b6 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -8,6 +8,7 @@ const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay, + mockForceStopRelayForTarget, mockMux, mockPtyProvider, mockFsProvider, @@ -31,6 +32,7 @@ const { disconnectAll: vi.fn() }, mockDeployAndLaunchRelay: vi.fn(), + mockForceStopRelayForTarget: vi.fn(), mockMux: { dispose: vi.fn(), isDisposed: vi.fn().mockReturnValue(false), @@ -87,6 +89,10 @@ vi.mock('../ssh/ssh-relay-deploy', () => ({ deployAndLaunchRelay: mockDeployAndLaunchRelay })) +vi.mock('../ssh/ssh-relay-reset', () => ({ + forceStopRelayForTarget: mockForceStopRelayForTarget +})) + vi.mock('../ssh/ssh-channel-multiplexer', () => ({ SshChannelMultiplexer: class MockSshChannelMultiplexer { constructor() { @@ -192,6 +198,7 @@ describe('SSH IPC handlers', () => { mockConnectionManager.getConnection.mockReset() mockConnectionManager.getState.mockReset() mockConnectionManager.disconnectAll.mockReset() + mockForceStopRelayForTarget.mockReset().mockResolvedValue(undefined) mockDeployAndLaunchRelay.mockReset().mockResolvedValue({ transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, @@ -205,6 +212,11 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onExit.mockReset() mockPtyProvider.onReplay.mockReset() mockPtyProvider.shutdown.mockReset() + mockPortForwardManager.addForward.mockReset() + mockPortForwardManager.removeForward.mockReset() + mockPortForwardManager.listForwards.mockReset().mockReturnValue([]) + mockPortForwardManager.removeAllForwards.mockReset() + mockPortForwardManager.dispose.mockReset() vi.mocked(getSshPtyProvider).mockReset() vi.mocked(getPtyIdsForConnection).mockReset().mockReturnValue([]) @@ -221,6 +233,7 @@ describe('SSH IPC handlers', () => { expect(channels).toContain('ssh:connect') expect(channels).toContain('ssh:disconnect') expect(channels).toContain('ssh:terminateSessions') + expect(channels).toContain('ssh:resetRelay') expect(channels).toContain('ssh:getState') expect(channels).toContain('ssh:testConnection') }) @@ -483,6 +496,206 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + it('ssh:resetRelay force-stops the remote relay and expires tracked leases', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(undefined) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }, + { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' } + ]) + vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-2']) + + await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) + + expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) + expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired') + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( + 'ssh-1', + 'pty-expired', + 'expired' + ) + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + }) + + it('ssh:resetRelay waits for an in-flight connect before tearing down the session', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + let resolveConnect!: (value: unknown) => void + const connectResult = new Promise((resolve) => { + resolveConnect = resolve + }) + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockReturnValue(connectResult) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + const connectPromise = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) + + const resetPromise = handlers.get('ssh:resetRelay')!(null, { + targetId: 'ssh-1' + }) as Promise + await Promise.resolve() + + expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() + expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() + + resolveConnect(conn) + await connectPromise + await resetPromise + + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + }) + + it('ssh:connect waits for an in-flight reset before starting a new connection', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const resetConn = {} + const connectConn = {} + let resolveForceStop!: () => void + const forceStopResult = new Promise((resolve) => { + resolveForceStop = resolve + }) + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.getConnection.mockReturnValue(resetConn) + mockConnectionManager.connect.mockResolvedValue(connectConn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockForceStopRelayForTarget.mockReturnValue(forceStopResult) + + const resetPromise = handlers.get('ssh:resetRelay')!(null, { + targetId: 'ssh-1' + }) as Promise + const connectPromise = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + + await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1)) + await Promise.resolve() + + expect(mockConnectionManager.connect).not.toHaveBeenCalled() + + resolveForceStop() + await resetPromise + await connectPromise + + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) + }) + + it('ssh:resetRelay reuses duplicate in-flight resets for the same target', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + let resolveForceStop!: () => void + let activeForceStops = 0 + let maxConcurrentForceStops = 0 + const forceStopResult = new Promise((resolve) => { + resolveForceStop = resolve + }) + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockForceStopRelayForTarget.mockImplementation(async () => { + activeForceStops += 1 + maxConcurrentForceStops = Math.max(maxConcurrentForceStops, activeForceStops) + await forceStopResult + activeForceStops -= 1 + }) + + const firstReset = handlers.get('ssh:resetRelay')!(null, { + targetId: 'ssh-1' + }) as Promise + const secondReset = handlers.get('ssh:resetRelay')!(null, { + targetId: 'ssh-1' + }) as Promise + + expect(secondReset).toBe(firstReset) + await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1)) + + resolveForceStop() + await Promise.all([firstReset, secondReset]) + + expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1) + expect(maxConcurrentForceStops).toBe(1) + expect(mockConnectionManager.disconnect).toHaveBeenCalledTimes(1) + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + }) + + it('ssh:resetRelay expires active-session leases instead of marking them terminated', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockStore.markSshRemotePtyLeases.mockClear() + mockStore.markSshRemotePtyLease.mockClear() + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' } + ]) + + await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) + + expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalledWith('ssh-1', 'terminated') + expect(mockStore.markSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1', 'detached') + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired') + expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') + }) + it('ssh:getState returns connection state', async () => { const state = { targetId: 'ssh-1', diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index d0b6ad845..a216eff55 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -7,15 +7,17 @@ import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-co import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { SshRelaySession } from '../ssh/ssh-relay-session' import { SshPortForwardManager } from '../ssh/ssh-port-forward' -import type { - SshTarget, - SshConnectionState, - SshConnectionStatus, - DetectedPort, - SavedPortForward +import { + DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS, + type DetectedPort, + type SavedPortForward, + type SshTarget, + type SshConnectionStatus, + type SshConnectionState } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isAuthError } from '../ssh/ssh-connection-utils' +import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' import { isSshPtyNotFoundError } from '../providers/ssh-pty-provider' import { registerSshBrowseHandler } from './ssh-browse' import { requestCredential, registerCredentialHandler } from './ssh-passphrase' @@ -42,7 +44,10 @@ function relayGracePeriodForTarget(target: SshTarget | null | undefined): number } // Why: cross-device sync should survive transient app closes, but an // unset value must not mean "keep remote PTYs forever" after disconnect. - return target.remoteWorkspaceSyncGracePeriodSeconds ?? 300 + return ( + target.remoteWorkspaceSyncGracePeriodSeconds ?? + DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS + ) } // Why: multiple renderer tabs for the same SSH target can fire ssh:connect @@ -52,6 +57,10 @@ function relayGracePeriodForTarget(target: SshTarget | null | undefined): number // awaits the first rather than racing. const connectInFlight = new Map>() +// Why: reset tears down and force-stops the relay, then disconnects SSH. +// Publish that lifecycle so new connects and duplicate resets cannot race it. +const resetRelayInFlight = new Map>() + // Why: ssh:testConnection calls connect() then disconnect(), which fires // state-change events to the renderer. This causes worktree cards to briefly // flash "connected" then "disconnected". Suppressing broadcasts during tests @@ -244,6 +253,7 @@ export function registerSshHandlers( 'ssh:connect', 'ssh:disconnect', 'ssh:terminateSessions', + 'ssh:resetRelay', 'ssh:getState', 'ssh:needsPassphrasePrompt', 'ssh:testConnection', @@ -369,6 +379,11 @@ export function registerSshHandlers( // ── Connection lifecycle ─────────────────────────────────────────── ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => { + const reset = resetRelayInFlight.get(args.targetId) + if (reset) { + await reset + } + // Why: serialize concurrent ssh:connect calls for the same target. // Multiple tabs can fire connect simultaneously; without this, they // interleave and the first session leaks. @@ -674,6 +689,74 @@ export function registerSshHandlers( await connectionManager!.disconnect(args.targetId) }) + async function doResetRelay(targetId: string, target: SshTarget): Promise { + const inFlightConnect = connectInFlight.get(targetId) + if (inFlightConnect) { + try { + // Why: reset tears down activeSessions; doing that while doConnect is + // still deploying can dispose the session doConnect is about to use. + await inFlightConnect + } catch { + // The reset can still recover a stale remote relay after a failed connect. + } + } + + const session = activeSessions.get(targetId) + if (session) { + await portForwardManager!.removeAllForwards(targetId) + // Why: reset has its own stale-relay lease semantics below. dispose() + // records clean PTY termination, which hides reset-affected leases. + session.detach() + activeSessions.delete(targetId) + clearRelayLostBackoff(targetId) + } + + const existingConn = connectionManager!.getConnection(targetId) + const conn = existingConn ?? (await connectionManager!.connect(target)) + try { + await forceStopRelayForTarget(conn, targetId) + } finally { + const ptyIds = new Set(getPtyIdsForConnection(targetId)) + for (const lease of store.getSshRemotePtyLeases(targetId)) { + if (lease.state !== 'terminated' && lease.state !== 'expired') { + ptyIds.add(lease.ptyId) + store.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + } + } + // Why: reset force-kills the remote relay daemon, so every local PTY + // handle owned by that relay is stale even if the reset command failed + // after the remote process accepted SIGTERM. + for (const ptyId of ptyIds) { + clearProviderPtyState(ptyId) + deletePtyOwnership(ptyId) + } + await connectionManager!.disconnect(targetId) + } + } + + ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => { + const existingReset = resetRelayInFlight.get(args.targetId) + if (existingReset) { + return existingReset + } + + const target = sshStore!.getTarget(args.targetId) + if (!target) { + throw new Error(`SSH target "${args.targetId}" not found`) + } + + let resetPromise: Promise + resetPromise = Promise.resolve() + .then(() => doResetRelay(args.targetId, target)) + .finally(() => { + if (resetRelayInFlight.get(args.targetId) === resetPromise) { + resetRelayInFlight.delete(args.targetId) + } + }) + resetRelayInFlight.set(args.targetId, resetPromise) + return resetPromise + }) + ipcMain.handle('ssh:getState', (_event, args: { targetId: string }) => { return getPublicSshState(args.targetId) }) diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index 52a0e1508..41efd5d45 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -27,7 +27,7 @@ export const PTY_FLOW_HIGH_WATERMARK = 100_000 export const PTY_FLOW_LOW_WATERMARK = 5_000 /** Reconnection grace period (default, overridable by relay --grace-time). */ -export const DEFAULT_GRACE_TIME_MS = 5 * 60 * 1000 // 5 minutes +export const DEFAULT_GRACE_TIME_MS = 3 * 60 * 60 * 1000 // 3 hours // ── Relay error codes ─────────────────────────────────────────────── diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 362b60ac3..5f9aede4b 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -52,6 +52,7 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand } from './ssh-relay-deploy-helpers' import type { SshConnection } from './ssh-connection' +import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' function makeMockConnection(): SshConnection { return { @@ -112,6 +113,44 @@ describe('deployAndLaunchRelay', () => { expect(progress).toContain('Starting relay...') }) + it('defaults fresh relays to the three-hour SSH disconnect grace window', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('/home/user') + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + mockExecCommand.mockResolvedValueOnce('DEAD') + mockExecCommand.mockResolvedValueOnce('READY') + + await deployAndLaunchRelay(conn) + + const launchCommand = vi + .mocked(conn.exec) + .mock.calls.map(([cmd]) => cmd as string) + .find((cmd) => cmd.includes('--detached')) + + expect(launchCommand).toContain(`--grace-time ${DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS}`) + }) + + it('allows an unlimited SSH disconnect grace window', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('/home/user') + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + mockExecCommand.mockResolvedValueOnce('DEAD') + mockExecCommand.mockResolvedValueOnce('READY') + + await deployAndLaunchRelay(conn, undefined, 0, 'target-a') + + const launchCommand = vi + .mocked(conn.exec) + .mock.calls.map(([cmd]) => cmd as string) + .find((cmd) => cmd.includes('--detached')) + + expect(launchCommand).toContain('--grace-time 0') + }) + it('uses a content-hashed versioned remote install directory', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 26d589495..52dfc39f2 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -5,7 +5,6 @@ import { join } from 'path' sequence and the GC's live-socket invariant. */ import { existsSync } from 'fs' import { app } from 'electron' -import { createHash } from 'crypto' import type { SshConnection } from './ssh-connection' import { parseUnameToRelayPlatform, type RelayPlatform } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' @@ -25,6 +24,12 @@ import { gcOldRelayVersions } from './ssh-relay-versioned-install' import { shellEscape } from './ssh-connection-utils' +import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' +import { + DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS +} from '../../shared/ssh-types' export type RelayDeployResult = { transport: MultiplexerTransport @@ -413,16 +418,20 @@ async function launchRelay( const nodePath = await resolveRemoteNodePath(conn) // Why: graceTimeSeconds originates from user-editable SshTarget config. // Clamping to integer prevents shell injection if the type ever loosened. - const requestedGraceTime = Math.floor(graceTimeSeconds ?? 300) - const graceTime = requestedGraceTime === 0 ? 0 : Math.max(60, Math.min(3600, requestedGraceTime)) + const requestedGraceTime = Math.floor(graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS) + const graceTime = + requestedGraceTime === 0 + ? 0 + : Math.max( + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requestedGraceTime) + ) const escapedDir = shellEscape(remoteDir) const escapedNode = shellEscape(nodePath) // Why: remoteRelayDir is shared by every Orca target for the same remote // account. Hashing the target ID into the socket name prevents one target // from attaching to another target's live relay. - const sockName = relayInstanceId - ? `relay-${hashRelayInstanceId(relayInstanceId)}.sock` - : 'relay.sock' + const sockName = relaySocketNameForInstanceId(relayInstanceId) const sockFile = `${remoteDir}/${sockName}` // Why: after an app restart a relay may still be running in its grace @@ -534,7 +543,3 @@ async function launchRelay( ) return waitForSentinel(channel) } - -function hashRelayInstanceId(relayInstanceId: string): string { - return createHash('sha256').update(relayInstanceId).digest('hex').slice(0, 16) -} diff --git a/src/main/ssh/ssh-relay-instance-id.ts b/src/main/ssh/ssh-relay-instance-id.ts new file mode 100644 index 000000000..381cafde4 --- /dev/null +++ b/src/main/ssh/ssh-relay-instance-id.ts @@ -0,0 +1,9 @@ +import { createHash } from 'crypto' + +export function hashRelayInstanceId(relayInstanceId: string): string { + return createHash('sha256').update(relayInstanceId).digest('hex').slice(0, 16) +} + +export function relaySocketNameForInstanceId(relayInstanceId: string | undefined): string { + return relayInstanceId ? `relay-${hashRelayInstanceId(relayInstanceId)}.sock` : 'relay.sock' +} diff --git a/src/main/ssh/ssh-relay-reset.test.ts b/src/main/ssh/ssh-relay-reset.test.ts new file mode 100644 index 000000000..5b4e56ab9 --- /dev/null +++ b/src/main/ssh/ssh-relay-reset.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn().mockResolvedValue('') +})) + +import { forceStopRelayForTarget } from './ssh-relay-reset' +import { execCommand } from './ssh-relay-deploy-helpers' +import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' +import type { SshConnection } from './ssh-connection' + +describe('forceStopRelayForTarget', () => { + it('targets only the relay socket for the requested SSH target', async () => { + const conn = {} as SshConnection + + await forceStopRelayForTarget(conn, 'ssh-1') + + const command = vi.mocked(execCommand).mock.calls[0]?.[1] ?? '' + expect(execCommand).toHaveBeenCalledWith(conn, expect.any(String)) + expect(command).toContain(`sock_name='${relaySocketNameForInstanceId('ssh-1')}'`) + expect(command).toContain('lsof -t -U "$sock"') + expect(command).toContain('pgrep -f "$sock_name"') + expect(command).toContain('rm -f "$sock"') + }) +}) diff --git a/src/main/ssh/ssh-relay-reset.ts b/src/main/ssh/ssh-relay-reset.ts new file mode 100644 index 000000000..b121b5a9c --- /dev/null +++ b/src/main/ssh/ssh-relay-reset.ts @@ -0,0 +1,37 @@ +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' + +export async function forceStopRelayForTarget( + conn: SshConnection, + relayInstanceId: string +): Promise { + const sockName = relaySocketNameForInstanceId(relayInstanceId) + const escapedSockName = shellEscape(sockName) + const script = [ + `sock_name=${escapedSockName}`, + 'base="${HOME}/.orca-remote"', + 'if [ -d "$base" ]; then', + ' for sock in "$base"/relay-*/"$sock_name" "$base"/"$sock_name"; do', + ' [ -S "$sock" ] || continue', + ' pid=""', + ' if command -v lsof >/dev/null 2>&1; then', + ' pid=$(lsof -t -U "$sock" 2>/dev/null | tr "\\n" " ")', + ' fi', + ' if [ -z "$pid" ] && command -v pgrep >/dev/null 2>&1; then', + ' pid=$(pgrep -f "$sock_name" 2>/dev/null | ' + + 'awk -v self="$$" -v parent="$PPID" \'$1 != self && $1 != parent\' | tr "\\n" " ")', + ' fi', + ' if [ -n "$pid" ]; then', + ' kill -TERM $pid 2>/dev/null || true', + ' sleep 0.2', + ' kill -KILL $pid 2>/dev/null || true', + ' fi', + ' rm -f "$sock"', + ' done', + 'fi' + ].join('\n') + + await execCommand(conn, script) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index fc17d2062..40ef93f6f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1787,6 +1787,7 @@ export type PreloadApi = { connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise + resetRelay: (args: { targetId: string }) => Promise getState: (args: { targetId: string }) => Promise needsPassphrasePrompt: (args: { targetId: string }) => Promise testConnection: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index e77cbdd07..174247743 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2783,6 +2783,9 @@ const api = { terminateSessions: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:terminateSessions', args), + resetRelay: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:resetRelay', args), + getState: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:getState', args), diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index d52b5b1ea..6e6f79a8c 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' +import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -332,9 +333,12 @@ describe('PtyHandler', () => { it('grace timer waits full period even when no PTYs exist', () => { const onExpire = vi.fn() + const defaultGraceMs = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 handler.startGraceTimer(onExpire) expect(onExpire).not.toHaveBeenCalled() - vi.advanceTimersByTime(5 * 60 * 1000) + vi.advanceTimersByTime(defaultGraceMs - 1) + expect(onExpire).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) expect(onExpire).toHaveBeenCalledTimes(1) }) @@ -347,10 +351,13 @@ describe('PtyHandler', () => { await dispatcher.callRequest('pty.spawn', {}) const onExpire = vi.fn() + const defaultGraceMs = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 handler.startGraceTimer(onExpire) expect(onExpire).not.toHaveBeenCalled() - vi.advanceTimersByTime(5 * 60 * 1000) + vi.advanceTimersByTime(defaultGraceMs - 1) + expect(onExpire).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) expect(onExpire).toHaveBeenCalledTimes(1) }) @@ -363,12 +370,13 @@ describe('PtyHandler', () => { await dispatcher.callRequest('pty.spawn', {}) const onExpire = vi.fn() + const defaultGraceMs = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 handler.startGraceTimer(onExpire) vi.advanceTimersByTime(60_000) handler.cancelGraceTimer() - vi.advanceTimersByTime(5 * 60 * 1000) + vi.advanceTimersByTime(defaultGraceMs) expect(onExpire).not.toHaveBeenCalled() }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 7cf3c9475..ef0508038 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -10,6 +10,7 @@ import { listShellProfiles } from './pty-shell-utils' import { getRelayShellLaunchConfig } from './pty-shell-launch' +import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' // Why: node-pty is a native addon that may not be installed on the remote. // Dynamic import keeps the require() lazy so loadPty() returns null gracefully @@ -81,7 +82,7 @@ function disposeManagedPty(managed: ManagedPty): void { /* swallow */ } } -const DEFAULT_GRACE_TIME_MS = 5 * 60 * 1000 +const DEFAULT_GRACE_TIME_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 export const REPLAY_BUFFER_MAX = 100 * 1024 const ALLOWED_SIGNALS = new Set([ 'SIGINT', diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 3ac7554f4..83ec0a52f 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -42,10 +42,11 @@ import { AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' +import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { assertPluginSourceUnderByteCap } from './plugin-source-limit' import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' -const DEFAULT_GRACE_MS = 5 * 60 * 1000 +const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 const SOCK_NAME = 'relay.sock' const CONNECT_TIMEOUT_MS = 5_000 diff --git a/src/renderer/src/components/settings/SshDestructiveActionDialog.tsx b/src/renderer/src/components/settings/SshDestructiveActionDialog.tsx new file mode 100644 index 000000000..c967f4d9a --- /dev/null +++ b/src/renderer/src/components/settings/SshDestructiveActionDialog.tsx @@ -0,0 +1,69 @@ +import { Loader2 } from 'lucide-react' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +type SshDestructiveActionDialogProps = { + open: boolean + title: string + description: string + targetLabel?: string + actionLabel: string + busyLabel?: string + isBusy?: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void | Promise +} + +export function SshDestructiveActionDialog({ + open, + title, + description, + targetLabel, + actionLabel, + busyLabel, + isBusy = false, + onOpenChange, + onConfirm +}: SshDestructiveActionDialogProps): React.JSX.Element { + return ( + { + if (isBusy && !nextOpen) { + return + } + onOpenChange(nextOpen) + }} + > + + + {title} + {description} + + + {targetLabel ? ( +
+
{targetLabel}
+
+ ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 42642c876..4d950ca97 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -1,20 +1,19 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { Plus, Upload } from 'lucide-react' -import type { SshTarget } from '../../../../shared/ssh-types' +import { + DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS, + DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + type SshTarget +} from '../../../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../../../shared/constants' import { useAppStore } from '@/store' import { Button } from '../ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '../ui/dialog' import type { SettingsSearchEntry } from './settings-search' import { SshTargetCard } from './SshTargetCard' +import { SshTargetDestructiveActions } from './SshTargetDestructiveActions' import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm' export const SSH_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ @@ -52,10 +51,6 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { const [editingId, setEditingId] = useState(null) const [form, setForm] = useState(EMPTY_FORM) const [testingIds, setTestingIds] = useState>(new Set()) - const [pendingRemove, setPendingRemove] = useState<{ id: string; label: string } | null>(null) - const [pendingTerminate, setPendingTerminate] = useState<{ id: string; label: string } | null>( - null - ) const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata) @@ -96,16 +91,22 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { } const graceSeconds = parseInt(form.relayGracePeriodSeconds, 10) - if (isNaN(graceSeconds) || graceSeconds < 60 || graceSeconds > 3600) { - toast.error('Relay grace period must be between 60 and 3600 seconds') + if ( + isNaN(graceSeconds) || + (graceSeconds !== 0 && graceSeconds < MIN_SSH_RELAY_GRACE_PERIOD_SECONDS) || + graceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS + ) { + toast.error('Relay grace period must be 0 or between 60 and 10800 seconds') return } const remoteGraceSeconds = parseInt(form.remoteWorkspaceSyncGracePeriodSeconds, 10) if ( form.remoteWorkspaceSyncEnabled && - (isNaN(remoteGraceSeconds) || remoteGraceSeconds < 0 || remoteGraceSeconds > 3600) + (isNaN(remoteGraceSeconds) || + remoteGraceSeconds < 0 || + remoteGraceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS) ) { - toast.error('Synced relay grace period must be between 0 and 3600 seconds') + toast.error('Synced relay grace period must be between 0 and 10800 seconds') return } @@ -119,7 +120,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { remoteWorkspaceSyncEnabled: form.remoteWorkspaceSyncEnabled, remoteWorkspaceSyncGracePeriodSeconds: form.remoteWorkspaceSyncEnabled ? remoteGraceSeconds - : 300, + : DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS, ...(form.identityFile.trim() ? { identityFile: form.identityFile.trim() } : {}), ...(form.proxyCommand.trim() ? { proxyCommand: form.proxyCommand.trim() } : {}), ...(form.jumpHost.trim() ? { jumpHost: form.jumpHost.trim() } : {}) @@ -181,10 +182,13 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { identityFile: target.identityFile ?? '', proxyCommand: target.proxyCommand ?? '', jumpHost: target.jumpHost ?? '', - relayGracePeriodSeconds: String(target.relayGracePeriodSeconds ?? 300), + relayGracePeriodSeconds: String( + target.relayGracePeriodSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS + ), remoteWorkspaceSyncEnabled: target.remoteWorkspaceSyncEnabled === true, remoteWorkspaceSyncGracePeriodSeconds: String( - target.remoteWorkspaceSyncGracePeriodSeconds ?? 300 + target.remoteWorkspaceSyncGracePeriodSeconds ?? + DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS ) }) setShowForm(true) @@ -215,6 +219,16 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { } } + const handleResetRelay = async (targetId: string): Promise => { + try { + await window.api.ssh.resetRelay({ targetId }) + toast.success('Remote relay reset') + await loadTargets() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to reset remote relay') + } + } + const handleTest = async (targetId: string): Promise => { setTestingIds((prev) => new Set(prev).add(targetId)) try { @@ -293,125 +307,55 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { - {/* Target list */} - {targets.length === 0 && !showForm ? ( -
- No SSH targets configured. -
- ) : ( -
- {targets.map((target) => ( - void handleConnect(id)} - onDisconnect={(id) => void handleDisconnect(id)} - onTerminateSessions={(id) => setPendingTerminate({ id, label: target.label })} - onTest={(id) => void handleTest(id)} - onEdit={handleEdit} - onRemove={(id) => setPendingRemove({ id, label: target.label })} - /> - ))} -
- )} - - {/* Add/Edit form */} - {showForm ? ( - void handleSave()} - onCancel={cancelForm} - /> - ) : null} - - {/* Remove confirmation dialog */} - { - if (!open) { - setPendingRemove(null) - } - }} + - - - Remove SSH Target - - This will remove the target and end any active remote terminals. - - + {({ busyActionForTarget, requestRemove, requestResetRelay, requestTerminateSessions }) => ( + <> + {/* Target list */} + {targets.length === 0 && !showForm ? ( +
+ No SSH targets configured. +
+ ) : ( +
+ {targets.map((target) => ( + + requestTerminateSessions({ id, label: target.label }) + } + onResetRelay={(id) => requestResetRelay({ id, label: target.label })} + onTest={handleTest} + onEdit={handleEdit} + onRemove={(id) => requestRemove({ id, label: target.label })} + /> + ))} +
+ )} - {pendingRemove ? ( -
-
{pendingRemove.label}
-
- ) : null} - - - - - -
-
- - {/* End remote terminals confirmation dialog */} - { - if (!open) { - setPendingTerminate(null) - } - }} - > - - - End Remote Terminals? - - This will stop active terminal sessions on this SSH target. Reconnecting will not - restore them. - - - - {pendingTerminate ? ( -
-
{pendingTerminate.label}
-
- ) : null} - - - - - -
-
+ {/* Add/Edit form */} + {showForm ? ( + void handleSave()} + onCancel={cancelForm} + /> + ) : null} + + )} + ) } diff --git a/src/renderer/src/components/settings/SshTargetCard.tsx b/src/renderer/src/components/settings/SshTargetCard.tsx index 4ed7dc117..d5ac86a6b 100644 --- a/src/renderer/src/components/settings/SshTargetCard.tsx +++ b/src/renderer/src/components/settings/SshTargetCard.tsx @@ -4,6 +4,7 @@ import { Loader2, MonitorSmartphone, Pencil, + RotateCcw, Server, ServerOff, Trash2 @@ -15,6 +16,7 @@ import type { } from '../../../../shared/ssh-types' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { isSshTargetConnecting, type SshTargetBusyAction } from './ssh-target-action-state' // ── Shared status helpers ──────────────────────────────────────────── @@ -46,20 +48,18 @@ export function statusColor(status: SshConnectionStatus): string { } } -export function isConnecting(status: SshConnectionStatus): boolean { - return ['connecting', 'deploying-relay', 'reconnecting'].includes(status) -} - // ── SshTargetCard ──────────────────────────────────────────────────── type SshTargetCardProps = { target: SshTarget state: SshConnectionState | undefined testing: boolean - onConnect: (targetId: string) => void - onDisconnect: (targetId: string) => void - onTerminateSessions: (targetId: string) => void - onTest: (targetId: string) => void + busyAction?: SshTargetBusyAction + onConnect: (targetId: string) => void | Promise + onDisconnect: (targetId: string) => void | Promise + onTerminateSessions: (targetId: string) => void | Promise + onResetRelay: (targetId: string) => void | Promise + onTest: (targetId: string) => void | Promise onEdit: (target: SshTarget) => void onRemove: (targetId: string) => void } @@ -68,17 +68,23 @@ export function SshTargetCard({ target, state, testing, + busyAction, onConnect, onDisconnect, onTerminateSessions, + onResetRelay, onTest, onEdit, onRemove }: SshTargetCardProps): React.JSX.Element { const status: SshConnectionStatus = state?.status ?? 'disconnected' const [actionInFlight, setActionInFlight] = useState< - 'connect' | 'disconnect' | 'terminate' | null + 'connect' | 'disconnect' | 'terminate' | 'reset' | null >(null) + const hasActionInFlight = actionInFlight !== null || busyAction !== undefined + const terminateInFlight = actionInFlight === 'terminate' || busyAction === 'terminate' + const resetInFlight = actionInFlight === 'reset' || busyAction === 'reset' + const removeInFlight = busyAction === 'remove' const handleConnect = (): void => { if (actionInFlight) { @@ -104,6 +110,14 @@ export function SshTargetCard({ Promise.resolve(onTerminateSessions(target.id)).finally(() => setActionInFlight(null)) } + const handleResetRelay = (): void => { + if (actionInFlight) { + return + } + setActionInFlight('reset') + Promise.resolve(onResetRelay(target.id)).finally(() => setActionInFlight(null)) + } + const renderEndRemoteTerminalsButton = (): React.JSX.Element => ( @@ -112,12 +126,10 @@ export function SshTargetCard({ size="icon" onClick={handleTerminateSessions} className="size-7 text-muted-foreground hover:text-red-400" - disabled={actionInFlight !== null} - aria-label={ - actionInFlight === 'terminate' ? 'Ending remote terminals' : 'End remote terminals' - } + disabled={hasActionInFlight} + aria-label={terminateInFlight ? 'Ending remote terminals' : 'End remote terminals'} > - {actionInFlight === 'terminate' ? ( + {terminateInFlight ? ( ) : ( @@ -130,9 +142,34 @@ export function SshTargetCard({ ) + const renderResetRelayButton = (): React.JSX.Element => ( + + + + + + Reset remote relay + + + ) + const renderSecondaryIconActions = (includeEndRemoteTerminals: boolean): React.JSX.Element => (
{includeEndRemoteTerminals ? renderEndRemoteTerminalsButton() : null} + {isSshTargetConnecting(status) ? null : renderResetRelayButton()} @@ -196,13 +239,13 @@ export function SshTargetCard({ size="xs" onClick={handleDisconnect} className="gap-1.5" - disabled={actionInFlight !== null} + disabled={hasActionInFlight} > Disconnect - ) : isConnecting(status) ? ( + ) : isSshTargetConnecting(status) ? ( <> {renderSecondaryIconActions(false)}
@@ -176,7 +182,7 @@ export function SshTargetForm({ } placeholder="0" min={0} - max={3600} + max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} />

How long synced remote workspace terminals stay alive after all clients disconnect. diff --git a/src/renderer/src/components/settings/ssh-target-action-state.ts b/src/renderer/src/components/settings/ssh-target-action-state.ts new file mode 100644 index 000000000..271dc9ae2 --- /dev/null +++ b/src/renderer/src/components/settings/ssh-target-action-state.ts @@ -0,0 +1,13 @@ +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +export type SshTargetBusyAction = 'terminate' | 'reset' | 'remove' + +const SSH_TARGET_CONNECTING_STATUSES: ReadonlySet = new Set([ + 'connecting', + 'deploying-relay', + 'reconnecting' +]) + +export function isSshTargetConnecting(status: SshConnectionStatus): boolean { + return SSH_TARGET_CONNECTING_STATUSES.has(status) +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index c820b9cc4..fa7034c91 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1112,6 +1112,7 @@ function createSshApi(): NonNullable['ssh']> { connect: () => Promise.resolve(null), disconnect: () => Promise.resolve(), terminateSessions: () => Promise.resolve(), + resetRelay: () => Promise.resolve(), getState: () => Promise.resolve(null), needsPassphrasePrompt: () => Promise.resolve(false), testConnection: () => diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 7814d9aa9..393c94d19 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -1,5 +1,11 @@ // ─── SSH Connection Types ─────────────────────────────────────────── +export const MIN_SSH_RELAY_GRACE_PERIOD_SECONDS = 60 +export const MAX_SSH_RELAY_GRACE_PERIOD_SECONDS = 3 * 60 * 60 +export const DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS = 3 * 60 * 60 +export const DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS = + DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS + export type SshTarget = { id: string label: string @@ -15,13 +21,14 @@ export type SshTarget = { /** Jump host (ProxyJump), if any. */ jumpHost?: string /** Grace period in seconds before relay shuts down after disconnect. - * Default: 300 (5 minutes). */ + * 0 disables expiry. Default: 10800 (3 hours). */ relayGracePeriodSeconds?: number /** Opt in to remote-host-owned workspace/session state for this SSH target. * Classic SSH remains local-session-backed when this is false/absent. */ remoteWorkspaceSyncEnabled?: boolean /** Grace period in seconds for synced remote workspace relays. - * 0 disables expiry. Only applies when remoteWorkspaceSyncEnabled is true. */ + * 0 disables expiry. Default: 10800 (3 hours). Only applies when + * remoteWorkspaceSyncEnabled is true. */ remoteWorkspaceSyncGracePeriodSeconds?: number /** Set to true after a successful connection that triggered a credential * prompt (passphrase or password). Persisted so startup reconnect can