Fix SSH relay persistence and reset controls (#2274)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-18 18:19:22 -04:00 committed by GitHub
parent 993d0ae599
commit c3bfc796af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 902 additions and 189 deletions

View File

@ -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<unknown>
await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1))
const resetPromise = handlers.get('ssh:resetRelay')!(null, {
targetId: 'ssh-1'
}) as Promise<void>
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<void>((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<void>
const connectPromise = handlers.get('ssh:connect')!(null, {
targetId: 'ssh-1'
}) as Promise<unknown>
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<void>((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<void>
const secondReset = handlers.get('ssh:resetRelay')!(null, {
targetId: 'ssh-1'
}) as Promise<void>
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',

View File

@ -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<string, Promise<SshConnectionState>>()
// 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<string, Promise<void>>()
// 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<void> {
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<void>
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)
})

View File

@ -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 ───────────────────────────────────────────────

View File

@ -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)

View File

@ -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)
}

View File

@ -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'
}

View File

@ -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"')
})
})

View File

@ -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<void> {
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)
}

View File

@ -1787,6 +1787,7 @@ export type PreloadApi = {
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
disconnect: (args: { targetId: string }) => Promise<void>
terminateSessions: (args: { targetId: string }) => Promise<void>
resetRelay: (args: { targetId: string }) => Promise<void>
getState: (args: { targetId: string }) => Promise<SshConnectionState | null>
needsPassphrasePrompt: (args: { targetId: string }) => Promise<boolean>
testConnection: (args: {

View File

@ -2783,6 +2783,9 @@ const api = {
terminateSessions: (args: { targetId: string }): Promise<void> =>
ipcRenderer.invoke('ssh:terminateSessions', args),
resetRelay: (args: { targetId: string }): Promise<void> =>
ipcRenderer.invoke('ssh:resetRelay', args),
getState: (args: { targetId: string }): Promise<SshConnectionState | null> =>
ipcRenderer.invoke('ssh:getState', args),

View File

@ -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()
})

View File

@ -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',

View File

@ -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

View File

@ -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<void>
}
export function SshDestructiveActionDialog({
open,
title,
description,
targetLabel,
actionLabel,
busyLabel,
isBusy = false,
onOpenChange,
onConfirm
}: SshDestructiveActionDialogProps): React.JSX.Element {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (isBusy && !nextOpen) {
return
}
onOpenChange(nextOpen)
}}
>
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
</DialogHeader>
{targetLabel ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all text-muted-foreground">{targetLabel}</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isBusy}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={isBusy} className="gap-1.5">
{isBusy ? <Loader2 className="size-3 animate-spin" /> : null}
{isBusy ? (busyLabel ?? actionLabel) : actionLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -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<string | null>(null)
const [form, setForm] = useState<EditingTarget>(EMPTY_FORM)
const [testingIds, setTestingIds] = useState<Set<string>>(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<void> => {
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<void> => {
setTestingIds((prev) => new Set(prev).add(targetId))
try {
@ -293,125 +307,55 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
</div>
</div>
{/* Target list */}
{targets.length === 0 && !showForm ? (
<div className="flex items-center justify-center rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
No SSH targets configured.
</div>
) : (
<div className="space-y-2">
{targets.map((target) => (
<SshTargetCard
key={target.id}
target={target}
state={sshConnectionStates.get(target.id)}
testing={testingIds.has(target.id)}
onConnect={(id) => 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 })}
/>
))}
</div>
)}
{/* Add/Edit form */}
{showForm ? (
<SshTargetForm
editingId={editingId}
form={form}
onFormChange={setForm}
onSave={() => void handleSave()}
onCancel={cancelForm}
/>
) : null}
{/* Remove confirmation dialog */}
<Dialog
open={!!pendingRemove}
onOpenChange={(open) => {
if (!open) {
setPendingRemove(null)
}
}}
<SshTargetDestructiveActions
connectionStates={sshConnectionStates}
onRemove={handleRemove}
onResetRelay={handleResetRelay}
onTerminateSessions={handleTerminateSessions}
>
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">Remove SSH Target</DialogTitle>
<DialogDescription className="text-xs">
This will remove the target and end any active remote terminals.
</DialogDescription>
</DialogHeader>
{({ busyActionForTarget, requestRemove, requestResetRelay, requestTerminateSessions }) => (
<>
{/* Target list */}
{targets.length === 0 && !showForm ? (
<div className="flex items-center justify-center rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
No SSH targets configured.
</div>
) : (
<div className="space-y-2">
{targets.map((target) => (
<SshTargetCard
key={target.id}
target={target}
state={sshConnectionStates.get(target.id)}
testing={testingIds.has(target.id)}
busyAction={busyActionForTarget(target.id)}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onTerminateSessions={(id) =>
requestTerminateSessions({ id, label: target.label })
}
onResetRelay={(id) => requestResetRelay({ id, label: target.label })}
onTest={handleTest}
onEdit={handleEdit}
onRemove={(id) => requestRemove({ id, label: target.label })}
/>
))}
</div>
)}
{pendingRemove ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all text-muted-foreground">{pendingRemove.label}</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setPendingRemove(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (pendingRemove) {
void handleRemove(pendingRemove.id)
setPendingRemove(null)
}
}}
>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* End remote terminals confirmation dialog */}
<Dialog
open={!!pendingTerminate}
onOpenChange={(open) => {
if (!open) {
setPendingTerminate(null)
}
}}
>
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">End Remote Terminals?</DialogTitle>
<DialogDescription className="text-xs">
This will stop active terminal sessions on this SSH target. Reconnecting will not
restore them.
</DialogDescription>
</DialogHeader>
{pendingTerminate ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all text-muted-foreground">{pendingTerminate.label}</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setPendingTerminate(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (pendingTerminate) {
void handleTerminateSessions(pendingTerminate.id)
setPendingTerminate(null)
}
}}
>
End Terminals
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add/Edit form */}
{showForm ? (
<SshTargetForm
editingId={editingId}
form={form}
onFormChange={setForm}
onSave={() => void handleSave()}
onCancel={cancelForm}
/>
) : null}
</>
)}
</SshTargetDestructiveActions>
</div>
)
}

View File

@ -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<void>
onDisconnect: (targetId: string) => void | Promise<void>
onTerminateSessions: (targetId: string) => void | Promise<void>
onResetRelay: (targetId: string) => void | Promise<void>
onTest: (targetId: string) => void | Promise<void>
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 => (
<Tooltip>
<TooltipTrigger asChild>
@ -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 ? (
<Loader2 className="size-3 animate-spin" />
) : (
<CircleStop className="size-3" />
@ -130,9 +142,34 @@ export function SshTargetCard({
</Tooltip>
)
const renderResetRelayButton = (): React.JSX.Element => (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleResetRelay}
className="size-7 text-muted-foreground hover:text-red-400"
disabled={hasActionInFlight}
aria-label={resetInFlight ? 'Resetting remote relay' : 'Reset remote relay'}
>
{resetInFlight ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RotateCcw className="size-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Reset remote relay
</TooltipContent>
</Tooltip>
)
const renderSecondaryIconActions = (includeEndRemoteTerminals: boolean): React.JSX.Element => (
<div className="flex items-center gap-1">
{includeEndRemoteTerminals ? renderEndRemoteTerminalsButton() : null}
{isSshTargetConnecting(status) ? null : renderResetRelayButton()}
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -140,6 +177,7 @@ export function SshTargetCard({
size="icon"
onClick={() => onEdit(target)}
className="size-7"
disabled={hasActionInFlight}
aria-label="Edit target"
>
<Pencil className="size-3" />
@ -156,9 +194,14 @@ export function SshTargetCard({
size="icon"
onClick={() => onRemove(target.id)}
className="size-7 text-muted-foreground hover:text-red-400"
aria-label="Remove target"
disabled={hasActionInFlight}
aria-label={removeInFlight ? 'Removing target' : 'Remove target'}
>
<Trash2 className="size-3" />
{removeInFlight ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
@ -196,13 +239,13 @@ export function SshTargetCard({
size="xs"
onClick={handleDisconnect}
className="gap-1.5"
disabled={actionInFlight !== null}
disabled={hasActionInFlight}
>
<ServerOff className="size-3" />
Disconnect
</Button>
</>
) : isConnecting(status) ? (
) : isSshTargetConnecting(status) ? (
<>
{renderSecondaryIconActions(false)}
<Button variant="ghost" size="xs" disabled className="gap-1.5">
@ -217,7 +260,7 @@ export function SshTargetCard({
variant="ghost"
size="xs"
onClick={() => onTest(target.id)}
disabled={testing}
disabled={testing || hasActionInFlight}
className="gap-1.5"
>
{testing ? (
@ -232,7 +275,7 @@ export function SshTargetCard({
size="xs"
onClick={handleConnect}
className="gap-1.5"
disabled={actionInFlight !== null}
disabled={hasActionInFlight}
>
{actionInFlight === 'connect' ? (
<Loader2 className="size-3 animate-spin" />

View File

@ -0,0 +1,205 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import { SshDestructiveActionDialog } from './SshDestructiveActionDialog'
import { isSshTargetConnecting, type SshTargetBusyAction } from './ssh-target-action-state'
type PendingTargetAction = { id: string; label: string }
type SshTargetDestructiveActionsRenderProps = {
busyActionForTarget: (targetId: string) => SshTargetBusyAction | undefined
requestRemove: (target: PendingTargetAction) => void
requestResetRelay: (target: PendingTargetAction) => void
requestTerminateSessions: (target: PendingTargetAction) => void
}
type SshTargetDestructiveActionsProps = {
connectionStates: Map<string, SshConnectionState>
onRemove: (targetId: string) => Promise<void>
onResetRelay: (targetId: string) => Promise<void>
onTerminateSessions: (targetId: string) => Promise<void>
children: (actions: SshTargetDestructiveActionsRenderProps) => ReactNode
}
export function SshTargetDestructiveActions({
connectionStates,
onRemove,
onResetRelay,
onTerminateSessions,
children
}: SshTargetDestructiveActionsProps): React.JSX.Element {
const [pendingRemove, setPendingRemove] = useState<PendingTargetAction | null>(null)
const [pendingReset, setPendingReset] = useState<PendingTargetAction | null>(null)
const [pendingTerminate, setPendingTerminate] = useState<PendingTargetAction | null>(null)
// Why: confirmed SSH actions keep running after the dialog click, so this
// state blocks overlapping relay/session teardown for the same target.
const targetActionsInFlightRef = useRef(new Map<string, SshTargetBusyAction>())
const [targetActionsInFlight, setTargetActionsInFlight] = useState<
Map<string, SshTargetBusyAction>
>(new Map())
const connectionStatesRef = useRef(connectionStates)
connectionStatesRef.current = connectionStates
const beginTargetAction = useCallback(
(targetId: string, action: SshTargetBusyAction): boolean => {
if (targetActionsInFlightRef.current.has(targetId)) {
return false
}
const nextActions = new Map(targetActionsInFlightRef.current)
nextActions.set(targetId, action)
targetActionsInFlightRef.current = nextActions
setTargetActionsInFlight(nextActions)
return true
},
[]
)
const finishTargetAction = useCallback((targetId: string): void => {
const nextActions = new Map(targetActionsInFlightRef.current)
nextActions.delete(targetId)
targetActionsInFlightRef.current = nextActions
setTargetActionsInFlight(nextActions)
}, [])
const runConfirmedTargetAction = async (
pendingTarget: PendingTargetAction | null,
action: SshTargetBusyAction,
operation: (targetId: string) => Promise<void>,
clearPendingTarget: () => void
): Promise<void> => {
if (!pendingTarget || !beginTargetAction(pendingTarget.id, action)) {
return
}
const targetId = pendingTarget.id
try {
await operation(targetId)
clearPendingTarget()
} finally {
finishTargetAction(targetId)
}
}
const pendingRemoveIsBusy =
pendingRemove !== null && targetActionsInFlight.get(pendingRemove.id) === 'remove'
const pendingResetIsBusy =
pendingReset !== null && targetActionsInFlight.get(pendingReset.id) === 'reset'
const pendingResetStatus =
pendingReset !== null
? (connectionStates.get(pendingReset.id)?.status ?? 'disconnected')
: 'disconnected'
const pendingResetBlockedByConnection =
pendingReset !== null && isSshTargetConnecting(pendingResetStatus)
const pendingTerminateIsBusy =
pendingTerminate !== null && targetActionsInFlight.get(pendingTerminate.id) === 'terminate'
useEffect(() => {
if (pendingResetBlockedByConnection && !pendingResetIsBusy) {
setPendingReset(null)
}
}, [pendingResetBlockedByConnection, pendingResetIsBusy])
const confirmResetRelay = async (): Promise<void> => {
if (!pendingReset) {
return
}
const latestStatus = connectionStatesRef.current.get(pendingReset.id)?.status ?? 'disconnected'
if (isSshTargetConnecting(latestStatus)) {
setPendingReset(null)
return
}
await runConfirmedTargetAction(pendingReset, 'reset', onResetRelay, () => setPendingReset(null))
}
const actions: SshTargetDestructiveActionsRenderProps = {
busyActionForTarget: (targetId) => targetActionsInFlight.get(targetId),
requestRemove: (target) => {
if (!targetActionsInFlightRef.current.has(target.id)) {
setPendingRemove(target)
}
},
requestResetRelay: (target) => {
const status = connectionStatesRef.current.get(target.id)?.status ?? 'disconnected'
if (!isSshTargetConnecting(status) && !targetActionsInFlightRef.current.has(target.id)) {
setPendingReset(target)
}
},
requestTerminateSessions: (target) => {
if (!targetActionsInFlightRef.current.has(target.id)) {
setPendingTerminate(target)
}
}
}
return (
<>
{children(actions)}
<SshDestructiveActionDialog
open={!!pendingRemove}
title="Remove SSH Target"
description="This will remove the target and end any active remote terminals."
targetLabel={pendingRemove?.label}
actionLabel="Remove"
busyLabel="Removing"
isBusy={pendingRemoveIsBusy}
onOpenChange={(open) => {
if (pendingRemoveIsBusy) {
return
}
if (!open) {
setPendingRemove(null)
}
}}
onConfirm={() =>
runConfirmedTargetAction(pendingRemove, 'remove', onRemove, () => setPendingRemove(null))
}
/>
<SshDestructiveActionDialog
open={!!pendingReset && (!pendingResetBlockedByConnection || pendingResetIsBusy)}
title="Reset Remote Relay?"
description="This force-stops the remote relay for this SSH target. Active remote terminals and port forwards for this target will end."
targetLabel={pendingReset?.label}
actionLabel="Reset Relay"
busyLabel="Resetting"
isBusy={pendingResetIsBusy}
onOpenChange={(open) => {
if (pendingResetIsBusy) {
return
}
if (!open) {
setPendingReset(null)
}
}}
onConfirm={confirmResetRelay}
/>
<SshDestructiveActionDialog
open={!!pendingTerminate}
title="End Remote Terminals?"
description="This will stop active terminal sessions on this SSH target. Reconnecting will not restore them."
targetLabel={pendingTerminate?.label}
actionLabel="End Terminals"
busyLabel="Ending"
isBusy={pendingTerminateIsBusy}
onOpenChange={(open) => {
if (pendingTerminateIsBusy) {
return
}
if (!open) {
setPendingTerminate(null)
}
}}
onConfirm={() =>
runConfirmedTargetAction(pendingTerminate, 'terminate', onTerminateSessions, () =>
setPendingTerminate(null)
)
}
/>
</>
)
}

View File

@ -1,4 +1,9 @@
import { FileKey } from 'lucide-react'
import {
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS,
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS
} from '../../../../shared/ssh-types'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
@ -26,9 +31,9 @@ export const EMPTY_FORM: EditingTarget = {
identityFile: '',
proxyCommand: '',
jumpHost: '',
relayGracePeriodSeconds: '300',
relayGracePeriodSeconds: String(DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS),
remoteWorkspaceSyncEnabled: false,
remoteWorkspaceSyncGracePeriodSeconds: '300'
remoteWorkspaceSyncGracePeriodSeconds: String(DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS)
}
type SshTargetFormProps = {
@ -136,12 +141,13 @@ export function SshTargetForm({
onChange={(e) =>
onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value }))
}
placeholder="300"
min={60}
max={3600}
placeholder={String(DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS)}
min={0}
max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}
/>
<p className="text-[11px] text-muted-foreground">
How long the relay keeps terminals alive after disconnect. Default: 300 (5 minutes).
How long the relay keeps terminals alive after disconnect. Default: 10800 (3 hours). 0
keeps it alive until terminals are ended or the relay is reset.
</p>
</div>
<div className="col-span-2 space-y-3 border-t border-border/50 pt-3">
@ -176,7 +182,7 @@ export function SshTargetForm({
}
placeholder="0"
min={0}
max={3600}
max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}
/>
<p className="text-[11px] text-muted-foreground">
How long synced remote workspace terminals stay alive after all clients disconnect.

View File

@ -0,0 +1,13 @@
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
export type SshTargetBusyAction = 'terminate' | 'reset' | 'remove'
const SSH_TARGET_CONNECTING_STATUSES: ReadonlySet<SshConnectionStatus> = new Set([
'connecting',
'deploying-relay',
'reconnecting'
])
export function isSshTargetConnecting(status: SshConnectionStatus): boolean {
return SSH_TARGET_CONNECTING_STATUSES.has(status)
}

View File

@ -1112,6 +1112,7 @@ function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
connect: () => Promise.resolve(null),
disconnect: () => Promise.resolve(),
terminateSessions: () => Promise.resolve(),
resetRelay: () => Promise.resolve(),
getState: () => Promise.resolve(null),
needsPassphrasePrompt: () => Promise.resolve(false),
testConnection: () =>

View File

@ -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