Improve SSH relay grace period settings (#2549)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-21 16:46:09 -04:00 committed by GitHub
parent ec6a8181da
commit dac61b84db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 361 additions and 152 deletions

View File

@ -1,9 +1,43 @@
import { describe, expect, it } from 'vitest'
import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import type {
RemoteWorkspaceSession,
RemoteWorkspaceSnapshot
} from '../../shared/remote-workspace-types'
import type { SshTarget } from '../../shared/ssh-types'
import type { WorkspaceSessionState } from '../../shared/types'
const {
getActiveMultiplexerMock,
getSshConnectionStoreMock,
registerRemoteWorkspaceNotificationHandlerMock
} = vi.hoisted(() => ({
getActiveMultiplexerMock: vi.fn(),
getSshConnectionStoreMock: vi.fn(),
registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn())
}))
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn(),
removeHandler: vi.fn()
}
}))
vi.mock('./ssh', () => ({
getActiveMultiplexer: getActiveMultiplexerMock,
getSshConnectionStore: getSshConnectionStoreMock
}))
vi.mock('./remote-workspace-events', () => ({
registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock
}))
import {
registerRemoteWorkspaceHandlers,
remoteWorkspaceSessionMatchesSnapshot
} from './remote-workspace'
function snapshot(session: RemoteWorkspaceSession): RemoteWorkspaceSnapshot {
return {
@ -15,6 +49,30 @@ function snapshot(session: RemoteWorkspaceSession): RemoteWorkspaceSnapshot {
}
}
const baseSession = {
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
} as WorkspaceSessionState
const targets: SshTarget[] = [
{
id: 'target-1',
label: 'Target 1',
host: 'one.example.com',
port: 22,
username: 'alice'
},
{
id: 'target-2',
label: 'Target 2',
host: 'two.example.com',
port: 22,
username: 'alice'
}
]
describe('remoteWorkspaceSessionMatchesSnapshot', () => {
it('matches normalized equivalent sessions', () => {
expect(
@ -85,3 +143,103 @@ describe('remoteWorkspaceSessionMatchesSnapshot', () => {
).toBe(false)
})
})
describe('remoteWorkspace:setForConnectedTargets', () => {
const handlers = new Map<string, (event: unknown, args: unknown) => unknown>()
const requestByTargetId = new Map<string, ReturnType<typeof vi.fn>>()
const muxByTargetId = new Map<string, { request: ReturnType<typeof vi.fn> }>()
const store = {
getRepo: vi.fn()
} as unknown as Store
beforeEach(() => {
handlers.clear()
requestByTargetId.clear()
muxByTargetId.clear()
vi.mocked(ipcMain.handle).mockReset()
vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => {
handlers.set(channel, handler as (event: unknown, args: unknown) => unknown)
})
vi.mocked(ipcMain.removeHandler).mockReset()
getSshConnectionStoreMock.mockReset()
getSshConnectionStoreMock.mockReturnValue({
listTargets: () => targets
})
getActiveMultiplexerMock.mockReset()
getActiveMultiplexerMock.mockImplementation((targetId: string) => {
let mux = muxByTargetId.get(targetId)
if (!mux) {
const request = vi.fn().mockImplementation((method: string) => {
if (method === 'workspace.get') {
return Promise.resolve(
snapshot({
activeWorktreePath: '/previous',
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
})
)
}
return Promise.resolve({
ok: true,
snapshot: snapshot({
activeWorktreePath: null,
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
})
})
})
mux = { request }
muxByTargetId.set(targetId, mux)
requestByTargetId.set(targetId, request)
}
return mux
})
registerRemoteWorkspaceNotificationHandlerMock.mockClear()
registerRemoteWorkspaceHandlers(store, () => null)
})
async function callSetForConnectedTargets(args: {
session: WorkspaceSessionState
hydratedTargetIds?: unknown
}): Promise<unknown> {
const handler = handlers.get('remoteWorkspace:setForConnectedTargets')
if (!handler) {
throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered')
}
return handler(null, args)
}
it('does not write without an explicit non-empty hydrated target set', async () => {
await expect(callSetForConnectedTargets({ session: baseSession })).resolves.toEqual([])
await expect(
callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: [] })
).resolves.toEqual([])
await expect(
callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1', 42] })
).resolves.toEqual([])
expect(getSshConnectionStoreMock).not.toHaveBeenCalled()
expect(getActiveMultiplexerMock).not.toHaveBeenCalled()
})
it('writes only to explicitly hydrated connected targets', async () => {
const result = await callSetForConnectedTargets({
session: baseSession,
hydratedTargetIds: ['target-1', 'missing-target']
})
expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }])
expect(getActiveMultiplexerMock).toHaveBeenCalledWith('target-1')
expect(getActiveMultiplexerMock).not.toHaveBeenCalledWith('target-2')
expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith(
'workspace.patch',
expect.objectContaining({
patch: expect.objectContaining({ kind: 'replace-session' })
})
)
expect(requestByTargetId.get('target-2')).toBeUndefined()
})
})

View File

@ -152,6 +152,17 @@ function normalizeConnectedClients(
.filter((entry): entry is RemoteWorkspaceConnectedClient => entry !== null)
}
function getExplicitHydratedTargetIds(value: unknown): Set<string> | null {
if (
!Array.isArray(value) ||
value.length === 0 ||
value.some((targetId) => typeof targetId !== 'string' || targetId.length === 0)
) {
return null
}
return new Set(value)
}
function targetForWorktree(store: Store, worktreeId: string): string | null {
const repoId = getRepoIdFromWorktreeId(worktreeId)
return store.getRepo(repoId)?.connectionId ?? null
@ -191,9 +202,6 @@ function importSessionForTarget(
}
async function getRemoteSnapshot(target: SshTarget): Promise<RemoteWorkspaceSnapshot | null> {
if (!target.remoteWorkspaceSyncEnabled) {
return null
}
const mux = getActiveMultiplexer(target.id)
if (!mux) {
return null
@ -221,7 +229,7 @@ export function handleRemoteWorkspaceNotification(
return
}
const target = getSshConnectionStore()?.getTarget(targetId)
if (!target?.remoteWorkspaceSyncEnabled) {
if (!target) {
return
}
const namespace = getRemoteWorkspaceNamespace(target)
@ -263,18 +271,18 @@ export function registerRemoteWorkspaceHandlers(
ipcMain.handle(
'remoteWorkspace:setForConnectedTargets',
async (_event, args: { session: WorkspaceSessionState; hydratedTargetIds?: string[] }) => {
const hydratedTargetIds = Array.isArray(args.hydratedTargetIds)
? new Set(args.hydratedTargetIds)
: null
async (_event, args: { session: WorkspaceSessionState; hydratedTargetIds?: unknown }) => {
const hydratedTargetIds = getExplicitHydratedTargetIds(args.hydratedTargetIds)
if (!hydratedTargetIds) {
// Why: an omitted hydration set used to broadcast one session to every
// SSH target, overwriting unrelated remote workspace snapshots.
return []
}
const targets =
getSshConnectionStore()
?.listTargets()
.filter(
(target) =>
target.remoteWorkspaceSyncEnabled &&
getActiveMultiplexer(target.id) &&
(!hydratedTargetIds || hydratedTargetIds.has(target.id))
(target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id)
) ?? []
const results: { targetId: string; result: RemoteWorkspacePatchResult }[] = []
@ -331,7 +339,7 @@ export function registerRemoteWorkspaceHandlers(
async () =>
getSshConnectionStore()
?.listTargets()
.filter((target) => target.remoteWorkspaceSyncEnabled && getActiveMultiplexer(target.id))
.filter((target) => getActiveMultiplexer(target.id))
.map((target) => target.id) ?? []
)
@ -344,7 +352,6 @@ export function registerRemoteWorkspaceHandlers(
?.listTargets()
.filter(
(target) =>
target.remoteWorkspaceSyncEnabled &&
getActiveMultiplexer(target.id) &&
(!requestedTargetIds || requestedTargetIds.has(target.id))
) ?? []

View File

@ -8,7 +8,6 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { SshRelaySession } from '../ssh/ssh-relay-session'
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
import {
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS,
type DetectedPort,
type SavedPortForward,
type SshTarget,
@ -39,15 +38,7 @@ let portForwardManager: SshPortForwardManager | null = null
const activeSessions = new Map<string, SshRelaySession>()
function relayGracePeriodForTarget(target: SshTarget | null | undefined): number | undefined {
if (!target?.remoteWorkspaceSyncEnabled) {
return target?.relayGracePeriodSeconds
}
// 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 ??
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS
)
return target?.relayGracePeriodSeconds
}
// Why: multiple renderer tabs for the same SSH target can fire ssh:connect

View File

@ -301,6 +301,69 @@ describe('Store', () => {
expect(repos[0].gitUsername).toBe('testuser')
})
it('normalizes legacy remote workspace sync fields on SSH targets', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {},
sshTargets: [
{
id: 'ssh-disabled-legacy-grace',
label: 'Disabled legacy grace',
host: 'disabled.example.com',
port: 22,
username: 'dev',
remoteWorkspaceSyncEnabled: false,
remoteWorkspaceSyncGracePeriodSeconds: 0
},
{
id: 'ssh-enabled-legacy-grace',
label: 'Enabled legacy grace',
host: 'enabled.example.com',
port: 22,
username: 'dev',
remoteWorkspaceSyncEnabled: true,
remoteWorkspaceSyncGracePeriodSeconds: 0
},
{
id: 'ssh-new-grace-period-wins',
label: 'New grace period',
host: 'new.example.com',
port: 22,
username: 'dev',
relayGracePeriodSeconds: 120,
remoteWorkspaceSyncEnabled: true,
remoteWorkspaceSyncGracePeriodSeconds: 0
}
]
})
const store = await createStore()
const targets = store.getSshTargets()
expect(targets[0]).not.toHaveProperty('relayGracePeriodSeconds')
expect(targets[1].relayGracePeriodSeconds).toBe(0)
expect(targets[2].relayGracePeriodSeconds).toBe(120)
for (const target of targets) {
expect(target).not.toHaveProperty('remoteWorkspaceSyncEnabled')
expect(target).not.toHaveProperty('remoteWorkspaceSyncGracePeriodSeconds')
}
store.flush()
const persisted = readDataFile() as { sshTargets?: Record<string, unknown>[] }
expect(persisted.sshTargets?.[0]).not.toHaveProperty('relayGracePeriodSeconds')
expect(persisted.sshTargets?.[1]?.relayGracePeriodSeconds).toBe(0)
expect(persisted.sshTargets?.[2]?.relayGracePeriodSeconds).toBe(120)
for (const target of persisted.sshTargets ?? []) {
expect(target).not.toHaveProperty('remoteWorkspaceSyncEnabled')
expect(target).not.toHaveProperty('remoteWorkspaceSyncGracePeriodSeconds')
}
})
it('drops malformed migration-unsupported PTY entries on load', async () => {
const repo = makeRepo()
writeDataFile({

View File

@ -240,10 +240,36 @@ function normalizeAutomationSessionReuse(automation: Automation): Automation {
}
}
type LegacySshTarget = SshTarget & {
remoteWorkspaceSyncEnabled?: unknown
remoteWorkspaceSyncGracePeriodSeconds?: unknown
}
// Why: old persisted targets predate configHost. Default to label-based lookup
// so imported SSH aliases keep resolving through ssh -G after upgrade.
function normalizeSshTarget(t: SshTarget): SshTarget {
return { ...t, configHost: t.configHost ?? t.label ?? t.host }
const target = { ...(t as LegacySshTarget) }
const legacySyncEnabled = target.remoteWorkspaceSyncEnabled
const currentGracePeriodSeconds = target.relayGracePeriodSeconds
const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds
// Why: remote workspace sync now follows the SSH relay lifecycle, so the
// retired per-target sync opt-out and grace-period fields stop at disk load.
delete target.remoteWorkspaceSyncEnabled
delete target.remoteWorkspaceSyncGracePeriodSeconds
delete target.relayGracePeriodSeconds
const relayGracePeriodSeconds =
currentGracePeriodSeconds ??
(legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number'
? legacyGracePeriodSeconds
: undefined)
const normalized: SshTarget = {
...target,
configHost: target.configHost ?? target.label ?? target.host
}
if (relayGracePeriodSeconds !== undefined) {
normalized.relayGracePeriodSeconds = relayGracePeriodSeconds
}
return normalized
}
// Why: shared by load-time merge and the IPC update handler so the same

View File

@ -52,7 +52,10 @@ 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'
import {
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS
} from '../../shared/ssh-types'
function makeMockConnection(): SshConnection {
return {
@ -151,6 +154,25 @@ describe('deployAndLaunchRelay', () => {
expect(launchCommand).toContain('--grace-time 0')
})
it('clamps configured SSH disconnect grace to the seven-day maximum', 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, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS + 1, '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 ${MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}`)
})
it('uses a content-hashed versioned remote install directory', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)

View File

@ -134,6 +134,18 @@ describe('PtyHandler', () => {
expect(onExpire).toHaveBeenCalledTimes(1)
})
it('does not expire an unlimited grace timer', () => {
const onExpire = vi.fn()
handler.startGraceTimer(onExpire, 100)
expect(handler.graceTimerActive).toBe(true)
handler.startGraceTimer(onExpire, 0)
expect(handler.graceTimerActive).toBe(false)
vi.advanceTimersByTime(100)
expect(onExpire).not.toHaveBeenCalled()
})
it('spawns a PTY and returns an id', async () => {
const result = await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
expect(result).toEqual({ id: 'pty-1' })

View File

@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Plus, Upload } from 'lucide-react'
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,
@ -90,23 +89,18 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
return
}
const graceSeconds = parseInt(form.relayGracePeriodSeconds, 10)
const graceSeconds = form.relayKeepAliveUntilReset
? 0
: parseInt(form.relayGracePeriodSeconds, 10)
if (
isNaN(graceSeconds) ||
(graceSeconds !== 0 && graceSeconds < MIN_SSH_RELAY_GRACE_PERIOD_SECONDS) ||
graceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS
!form.relayKeepAliveUntilReset &&
(isNaN(graceSeconds) ||
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 > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS)
) {
toast.error('Synced relay grace period must be between 0 and 10800 seconds')
toast.error(
`Relay grace period must be between 60 and ${MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} seconds, or choose keep alive until reset`
)
return
}
@ -117,10 +111,6 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
port,
username: form.username.trim(),
relayGracePeriodSeconds: graceSeconds,
remoteWorkspaceSyncEnabled: form.remoteWorkspaceSyncEnabled,
remoteWorkspaceSyncGracePeriodSeconds: form.remoteWorkspaceSyncEnabled
? remoteGraceSeconds
: 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() } : {})
@ -183,13 +173,11 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
proxyCommand: target.proxyCommand ?? '',
jumpHost: target.jumpHost ?? '',
relayGracePeriodSeconds: String(
target.relayGracePeriodSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
target.relayGracePeriodSeconds === 0
? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
: (target.relayGracePeriodSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS)
),
remoteWorkspaceSyncEnabled: target.remoteWorkspaceSyncEnabled === true,
remoteWorkspaceSyncGracePeriodSeconds: String(
target.remoteWorkspaceSyncGracePeriodSeconds ??
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS
)
relayKeepAliveUntilReset: target.relayGracePeriodSeconds === 0
})
setShowForm(true)
}

View File

@ -1,8 +1,8 @@
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
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS,
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS
} from '../../../../shared/ssh-types'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
@ -18,8 +18,7 @@ export type EditingTarget = {
proxyCommand: string
jumpHost: string
relayGracePeriodSeconds: string
remoteWorkspaceSyncEnabled: boolean
remoteWorkspaceSyncGracePeriodSeconds: string
relayKeepAliveUntilReset: boolean
}
export const EMPTY_FORM: EditingTarget = {
@ -32,8 +31,7 @@ export const EMPTY_FORM: EditingTarget = {
proxyCommand: '',
jumpHost: '',
relayGracePeriodSeconds: String(DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS),
remoteWorkspaceSyncEnabled: false,
remoteWorkspaceSyncGracePeriodSeconds: String(DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS)
relayKeepAliveUntilReset: false
}
type SshTargetFormProps = {
@ -136,60 +134,36 @@ export function SshTargetForm({
<div className="col-span-2 space-y-1.5">
<Label>Relay Grace Period (seconds)</Label>
<Input
type="number"
value={form.relayGracePeriodSeconds}
type={form.relayKeepAliveUntilReset ? 'text' : 'number'}
value={form.relayKeepAliveUntilReset ? 'Until reset' : form.relayGracePeriodSeconds}
onChange={(e) =>
onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value }))
}
placeholder={String(DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS)}
min={0}
min={MIN_SSH_RELAY_GRACE_PERIOD_SECONDS}
max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}
disabled={form.relayKeepAliveUntilReset}
/>
<p className="text-[11px] text-muted-foreground">
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">
<label className="flex items-start gap-3 text-sm">
<label className="flex cursor-pointer items-start gap-2.5 py-1 text-xs">
<input
type="checkbox"
className="mt-0.5 size-4 accent-foreground"
checked={form.remoteWorkspaceSyncEnabled}
className="mt-0.5 size-3.5 shrink-0 accent-foreground"
checked={form.relayKeepAliveUntilReset}
onChange={(e) =>
onFormChange((f) => ({ ...f, remoteWorkspaceSyncEnabled: e.target.checked }))
onFormChange((f) => ({ ...f, relayKeepAliveUntilReset: e.target.checked }))
}
/>
<span className="space-y-1">
<span className="block font-medium">Sync remote workspace</span>
<span className="block text-[11px] text-muted-foreground">
Store terminal tabs and split layouts on the SSH host so another Orca client can
restore the same remote workspace.
<span className="space-y-0.5">
<span className="block font-medium text-foreground">Keep alive until reset</span>
<span className="block text-muted-foreground">
Remote terminals stay available until you end them or reset the relay.
</span>
</span>
</label>
{form.remoteWorkspaceSyncEnabled && (
<div className="space-y-1.5 pl-7">
<Label>Synced Relay Grace Period (seconds)</Label>
<Input
type="number"
value={form.remoteWorkspaceSyncGracePeriodSeconds}
onChange={(e) =>
onFormChange((f) => ({
...f,
remoteWorkspaceSyncGracePeriodSeconds: e.target.value
}))
}
placeholder="0"
min={0}
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.
0 keeps them alive until explicitly terminated.
</p>
</div>
)}
<p className="text-[11px] text-muted-foreground">
How long the relay keeps terminals alive after disconnect. Default: 10800 (3 hours).
Maximum: {MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} (7 days).
</p>
</div>
</div>

View File

@ -105,13 +105,11 @@ function TargetRow({
targetId,
label,
status,
syncEnabled,
syncStatus
}: {
targetId: string
label: string
status: SshConnectionStatus
syncEnabled: boolean
syncStatus: RemoteWorkspaceSyncStatus | undefined
}): React.JSX.Element {
const [busy, setBusy] = useState(false)
@ -145,23 +143,17 @@ function TargetRow({
<div className="truncate text-[12px] font-medium">{label}</div>
<div className="flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{STATUS_LABELS[status]}</span>
{syncEnabled && (
<>
<span aria-hidden="true">·</span>
<span
className={`inline-flex min-w-0 items-center gap-1 ${syncStatusTone(syncStatus)}`}
>
{syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? (
<Loader2 className="size-2.5 shrink-0 animate-spin" />
) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? (
<AlertTriangle className="size-2.5 shrink-0" />
) : (
<Cloud className="size-2.5 shrink-0" />
)}
<span className="truncate">{syncStatusLabel(syncStatus)}</span>
</span>
</>
)}
<span aria-hidden="true">·</span>
<span className={`inline-flex min-w-0 items-center gap-1 ${syncStatusTone(syncStatus)}`}>
{syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? (
<Loader2 className="size-2.5 shrink-0 animate-spin" />
) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? (
<AlertTriangle className="size-2.5 shrink-0" />
) : (
<Cloud className="size-2.5 shrink-0" />
)}
<span className="truncate">{syncStatusLabel(syncStatus)}</span>
</span>
</div>
</div>
{busy ? (
@ -196,7 +188,6 @@ export function SshStatusSegment({
}): React.JSX.Element | null {
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshTargetRemoteSyncEnabled = useAppStore((s) => s.sshTargetRemoteSyncEnabled)
const remoteWorkspaceSyncStatusByTargetId = useAppStore(
(s) => s.remoteWorkspaceSyncStatusByTargetId
)
@ -209,7 +200,6 @@ export function SshStatusSegment({
id,
label,
status: (state?.status ?? 'disconnected') as SshConnectionStatus,
syncEnabled: sshTargetRemoteSyncEnabled.get(id) === true,
syncStatus: remoteWorkspaceSyncStatusByTargetId[id]
}
})
@ -293,7 +283,6 @@ export function SshStatusSegment({
targetId={t.id}
label={t.label}
status={t.status}
syncEnabled={t.syncEnabled}
syncStatus={t.syncStatus}
/>
))}

View File

@ -363,9 +363,6 @@ async function applyRemoteWorkspaceSnapshot(
async function syncRemoteWorkspaceAfterConnect(targetId: string): Promise<void> {
const store = useAppStore.getState()
if (store.sshTargetRemoteSyncEnabled.get(targetId) !== true) {
return
}
if (!(await prepareRemoteWorkspaceTarget(targetId))) {
store.setRemoteWorkspaceSyncStatus(targetId, {
phase: 'error',
@ -1588,14 +1585,12 @@ export function useIpcEvents(): void {
useAppStore.getState().setPortForwards(target.id, forwards)
useAppStore.getState().setDetectedPorts(target.id, detected)
}
if (target.remoteWorkspaceSyncEnabled) {
void syncRemoteWorkspaceAfterConnect(target.id).catch((err) => {
useAppStore.getState().setRemoteWorkspaceSyncStatus(target.id, {
phase: 'error',
message: err instanceof Error ? err.message : 'Workspace sync failed'
})
void syncRemoteWorkspaceAfterConnect(target.id).catch((err) => {
useAppStore.getState().setRemoteWorkspaceSyncStatus(target.id, {
phase: 'error',
message: err instanceof Error ? err.message : 'Workspace sync failed'
})
}
})
}
}
}

View File

@ -28,7 +28,6 @@ export type SshSlice = {
/** Maps target IDs to their user-facing labels. Populated during hydration
* so components can look up labels without per-component IPC calls. */
sshTargetLabels: Map<string, string>
sshTargetRemoteSyncEnabled: Map<string, boolean>
remoteWorkspaceHydratedTargetIds: Set<string>
remoteWorkspaceSyncStatusByTargetId: Record<string, RemoteWorkspaceSyncStatus>
sshCredentialQueue: SshCredentialRequest[]
@ -46,9 +45,7 @@ export type SshSlice = {
detectedPortsByConnection: Record<string, DetectedPort[]>
setSshConnectionState: (targetId: string, state: SshConnectionState) => void
setSshTargetLabels: (labels: Map<string, string>) => void
setSshTargetsMetadata: (
targets: Pick<SshTarget, 'id' | 'label' | 'remoteWorkspaceSyncEnabled'>[]
) => void
setSshTargetsMetadata: (targets: Pick<SshTarget, 'id' | 'label'>[]) => void
markRemoteWorkspaceHydrated: (targetId: string) => void
clearRemoteWorkspaceHydrated: (targetId: string) => void
setRemoteWorkspaceSyncStatus: (targetId: string, status: RemoteWorkspaceSyncStatus) => void
@ -62,7 +59,6 @@ export type SshSlice = {
export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set) => ({
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
sshTargetRemoteSyncEnabled: new Map(),
remoteWorkspaceHydratedTargetIds: new Set(),
remoteWorkspaceSyncStatusByTargetId: {},
sshCredentialQueue: [],
@ -87,10 +83,7 @@ export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set) =>
setSshTargetLabels: (labels) => set({ sshTargetLabels: labels }),
setSshTargetsMetadata: (targets) =>
set({
sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])),
sshTargetRemoteSyncEnabled: new Map(
targets.map((target) => [target.id, target.remoteWorkspaceSyncEnabled === true])
)
sshTargetLabels: new Map(targets.map((target) => [target.id, target.label]))
}),
markRemoteWorkspaceHydrated: (targetId) =>
set((s) => {

View File

@ -1,10 +1,8 @@
// ─── 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 MAX_SSH_RELAY_GRACE_PERIOD_SECONDS = 7 * 24 * 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
@ -21,15 +19,8 @@ export type SshTarget = {
/** Jump host (ProxyJump), if any. */
jumpHost?: string
/** Grace period in seconds before relay shuts down after disconnect.
* 0 disables expiry. Default: 10800 (3 hours). */
* 0 disables expiry. Default: 10800 (3 hours). Max: 604800 (7 days). */
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. 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
* partition targets into eager (no passphrase) vs deferred (passphrase)