feat(ssh): per-host Reconnect on launch toggle (#33935)

* feat(ssh): per-host Reconnect on launch toggle

A host with Reconnect on launch turned off starts each launch as if the user
had disconnected it: main records the session user-disconnect for it and the
managed servers reached through it, so neither the startup restore, pane
mounts nor managed-server calls dial it until the user connects it. The
renderer also skips it in the startup restore. The switch lives in Settings ->
SSH -> Advanced for hosts this desktop dials; undefined keeps today's behaviour.

* fix(ssh): hold opted-out hosts before server status probes start

Server status probes start inside registerCoreHandlers, before the SSH
handlers register, so hold launch opt-outs right before it (once per
process). Relay-era panes that hit a held host now wait for Connect instead
of reporting a failed connect.
This commit is contained in:
Neil
2026-10-26 06:01:54 -07:00
committed by GitHub
parent c9eee6b54c
commit 639d52952d
23 changed files with 205 additions and 14 deletions
+2
View File
@@ -41,6 +41,8 @@ On the first connect, Orca installs itself on the host over your own SSH connect
Open **Settings → SSH**, edit a target, and expand **Advanced Connection** when the host needs a proxy, jump host, or SSH multiplexing override. Orca enables **Reuse SSH connection for faster setup** by default, which uses OpenSSH connection reuse on macOS and Linux so setup commands do not each pay a fresh SSH handshake. Turn it off only for hosts whose SSH policy rejects multiplexed sessions.
**Reconnect on launch** is on by default: hosts that were connected when you quit reconnect when Orca starts. Turn it off for a host you only want on demand, such as a machine that is often asleep or off the network. Orca then leaves it, and any Orca server reached through it, disconnected at launch, and its workspaces show **Connect** until you connect it. Scheduled automations on that host are skipped until then.
## Passphrases
Passphrases are held in memory for the life of the Orca session. Closing Orca clears them. You can opt into a longer TTL under SSH settings.
@@ -38,6 +38,10 @@ import {
isRuntimeEnvironmentManuallyDisconnected
} from '../runtime-environments/runtime-environment-manual-disconnect'
import { isSshUserDisconnected } from '../../ssh/ssh-user-disconnect'
import {
holdLaunchReconnectOptOuts,
resetLaunchReconnectOptOutsForTests
} from './ssh-host-user-disconnect'
import { createSshIpcHarness } from './ssh-ipc-test-harness'
const { mockSshStore, mockConnectionManager } = mocks
@@ -152,3 +156,39 @@ describe('a user Disconnect of an SSH host', () => {
expect(isRuntimeEnvironmentManuallyDisconnected(managedServer.id)).toBe(true)
})
})
describe('a host opted out of reconnect on launch', () => {
const harness = createSshIpcHarness(mocks)
const { handlers } = harness
beforeEach(async () => {
await harness.reset()
runtime.environments = [managedServer, otherServer]
runtime.getStatusOwner.mockReset().mockReturnValue({ activate: runtime.activate })
runtime.activate.mockReset()
mockSshStore.getTarget.mockReturnValue({ ...target, reconnectOnLaunch: false })
})
afterEach(() => {
clearRuntimeEnvironmentManualDisconnect(managedServer.id)
})
it('is not dialed at launch, nor is its managed server, until the user connects it', async () => {
// Handler registration already took this process's hold with no hosts; take it again here.
resetLaunchReconnectOptOutsForTests()
holdLaunchReconnectOptOuts([{ ...target, reconnectOnLaunch: false }, { id: 'ssh-2' }], '/tmp')
await expect(
handlers.get('ssh:connect')!(null, { targetId: target.id, automatic: true })
).rejects.toThrow('Server was disconnected. Connect to use it again.')
expect(mockConnectionManager.connect).not.toHaveBeenCalled()
expect(isRuntimeEnvironmentManuallyDisconnected(managedServer.id)).toBe(true)
expect(isSshUserDisconnected('ssh-2')).toBe(false)
await Promise.resolve(handlers.get('ssh:connect')!(null, { targetId: target.id })).catch(
() => {}
)
expect(isSshUserDisconnected(target.id)).toBe(false)
expect(isRuntimeEnvironmentManuallyDisconnected(managedServer.id)).toBe(false)
})
})
@@ -5,6 +5,7 @@ import {
getRuntimeSshAccess,
type KnownRuntimeEnvironment
} from '../../../shared/runtime-environments'
import type { SshTarget } from '../../../shared/ssh-types'
import {
clearSshUserDisconnected,
clearSshUserDisconnectedForEnvironment,
@@ -98,6 +99,32 @@ export async function liftSshUserDisconnectForEnvironment(
clearSshUserDisconnectedForEnvironment(environment)
}
let launchReconnectOptOutsHeld = false
/** Opted-out hosts start each launch as if the user had disconnected them: nothing dials them, servers included, until Connect. Once per process, before server status probes start. */
export function holdLaunchReconnectOptOuts(
targets: readonly Pick<SshTarget, 'id' | 'reconnectOnLaunch'>[],
userDataPath: string = appUserDataPath()
): void {
if (launchReconnectOptOutsHeld) {
return
}
launchReconnectOptOutsHeld = true
for (const target of targets) {
if (target.reconnectOnLaunch !== false) {
continue
}
recordSshUserDisconnected(target.id)
for (const environment of environmentsReachedThrough(userDataPath, target.id)) {
markRuntimeEnvironmentManuallyDisconnected(environment.id)
}
}
}
export function resetLaunchReconnectOptOutsForTests(): void {
launchReconnectOptOutsHeld = false
}
/** Explicit host Connect lifts its servers; resume their status checks only after connection settles. */
export function liftSshHostUserDisconnect(targetId: string): string[] {
clearSshUserDisconnected(targetId)
+6
View File
@@ -2,6 +2,10 @@ import { ipcMain, type BrowserWindow } from 'electron'
import type { Store } from '../../persistence'
import { SshConnectionStore } from '../../ssh/connection/ssh-connection-store'
import { awaitSignInForCredentialHosts } from './ssh-sign-in-requirement'
import {
holdLaunchReconnectOptOuts,
resetLaunchReconnectOptOutsForTests
} from './ssh-host-user-disconnect'
import { SshConnectionManager } from '../../ssh/connection/ssh-connection-manager'
import { SshPortForwardManager } from '../../ssh/port-forwarding/ssh-port-forward'
import { quitTeardownStartGate } from '../../quit-teardown-start-gate'
@@ -119,6 +123,7 @@ export function registerSshHandlers(
setConnectionManager(new SshConnectionManager(callbacks))
// Window re-registration must not re-gate hosts already signed in during this process.
awaitSignInForCredentialHosts(getSshTargetRegistryStore()!)
holdLaunchReconnectOptOuts(getSshTargetRegistryStore()!.listTargets())
}
setPortForwardManager(portForwardManager ?? new SshPortForwardManager())
portForwardManager!.setCallbacks({
@@ -190,6 +195,7 @@ export async function resetSshHandlerStateForTests(): Promise<void> {
credentialRequestedForTarget.clear()
resetSshCredentialDeclinesForTests()
resetSshUserDisconnectsForTests()
resetLaunchReconnectOptOutsForTests()
quitTeardownStartGate.resetForTests()
resetSshShutdownDrain()
+2 -1
View File
@@ -1,3 +1,4 @@
import { formatSshUserDisconnectedMessage } from '../../shared/ssh-user-disconnect-message'
import {
getRuntimeSshAccess,
type KnownRuntimeEnvironment
@@ -35,7 +36,7 @@ export function resetSshUserDisconnectsForTests(): void {
/** Not an auth or transient failure: callers neither ask for a sign-in nor retry it. */
export function createSshUserDisconnectedError(label: string): Error {
return Object.assign(new Error(`${label} was disconnected. Connect to use it again.`), {
return Object.assign(new Error(formatSshUserDisconnectedMessage(label)), {
code: 'ssh_user_disconnected'
})
}
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
attachMainWindowServicesMock,
holdLaunchReconnectOptOutsMock,
initTccPromptNoticeMock,
preserveAgentAuthBeforeRestartMock,
registerCoreHandlersMock,
@@ -11,10 +12,12 @@ const {
const store = {
flushPendingOrThrowAsync: vi.fn(async () => {}),
writeLatestProfileStateJsonExportAsync: vi.fn(async () => {}),
getSettings: vi.fn(() => ({}))
getSettings: vi.fn(() => ({})),
getSshTargets: vi.fn(() => [{ id: 'ssh-1', reconnectOnLaunch: false }])
}
return {
attachMainWindowServicesMock: vi.fn(),
holdLaunchReconnectOptOutsMock: vi.fn(),
initTccPromptNoticeMock: vi.fn(),
preserveAgentAuthBeforeRestartMock: vi.fn(() => Promise.resolve()),
registerCoreHandlersMock: vi.fn(),
@@ -53,6 +56,9 @@ vi.mock('../ipc/register-core-handlers/register-core-handlers', () => ({
vi.mock('../window/attach-main-window-services', () => ({
attachMainWindowServices: attachMainWindowServicesMock
}))
vi.mock('../ipc/ssh/ssh-host-user-disconnect', () => ({
holdLaunchReconnectOptOuts: holdLaunchReconnectOptOutsMock
}))
vi.mock('../macos-tcc-prompt-notice', () => ({ initTccPromptNotice: initTccPromptNoticeMock }))
vi.mock('../updater', () => ({ resolveUpdateInstallMode: vi.fn(() => 'interactive') }))
vi.mock('./main-process-state', () => ({ mainProcessState: state }))
@@ -76,6 +82,24 @@ vi.mock('./main-window-lifecycle-flags', () => ({ isRecoveryReloadInFlight: vi.f
const { attachMainWindowCoreServices } = await import('./main-window-core-services')
describe('main window launch reconnect opt-outs', () => {
it('holds opted-out SSH hosts before core handlers start server status probes', () => {
vi.clearAllMocks()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: mocked BrowserWindow only needs webContents for this composition-root wiring test.
attachMainWindowCoreServices({ webContents: { id: 17 } } as never, {
markExpectedRendererReload: vi.fn(),
recordRendererReload: vi.fn()
})
expect(holdLaunchReconnectOptOutsMock).toHaveBeenCalledWith([
{ id: 'ssh-1', reconnectOnLaunch: false }
])
expect(holdLaunchReconnectOptOutsMock.mock.invocationCallOrder[0]).toBeLessThan(
registerCoreHandlersMock.mock.invocationCallOrder[0]
)
})
})
describe('main window profile-state update preparation', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -21,6 +21,7 @@ import {
} from './codex-session-resume-launch'
import { isRecoveryReloadInFlight } from './main-window-lifecycle-flags'
import { RELAY_HOST_CLOSE_REASON } from '../../shared/relay-host-close-reason'
import { holdLaunchReconnectOptOuts } from '../ipc/ssh/ssh-host-user-disconnect'
export function attachMainWindowCoreServices(
window: BrowserWindow,
@@ -61,6 +62,8 @@ export function attachMainWindowCoreServices(
) {
throw new Error('Main window services must be initialized before attaching')
}
// Server status probes start inside registerCoreHandlers; held hosts must be marked first.
holdLaunchReconnectOptOuts(store.getSshTargets())
registerCoreHandlers(
store,
runtime,
@@ -15,7 +15,8 @@ export function SshHostAdvancedFields({
form,
disabled,
onFormChange,
offerProxyConnectionReuse = false
offerProxyConnectionReuse = false,
offerReconnectOnLaunch = false
}: {
open: boolean
onOpenChange: (open: boolean) => void
@@ -24,6 +25,8 @@ export function SshHostAdvancedFields({
onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void
/** Only for hosts this Windows desktop dials itself; a paired server runs its own ssh. */
offerProxyConnectionReuse?: boolean
/** Only for hosts this desktop restores at launch; a paired server keeps its own hosts. */
offerReconnectOnLaunch?: boolean
}): React.JSX.Element {
return (
<Collapsible open={open} onOpenChange={onOpenChange} className="col-span-2 sm:col-span-2">
@@ -140,6 +143,35 @@ export function SshHostAdvancedFields({
/>
</div>
) : null}
{offerReconnectOnLaunch ? (
<div className="flex items-start justify-between gap-4 py-1 text-xs">
<div className="min-w-0 flex-1 space-y-0.5">
<Label className="text-xs font-medium">
{translate(
'auto.components.settings.SshTargetForm.reconnectOnLaunch',
'Reconnect on launch'
)}
</Label>
<p className="text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.reconnectOnLaunchHelp',
'When off, Orca leaves this host, and any Orca server reached through it, disconnected at launch until you connect it.'
)}
</p>
</div>
<SettingsSwitch
checked={form.reconnectOnLaunch}
disabled={disabled}
onChange={() =>
onFormChange((f) => ({ ...f, reconnectOnLaunch: !f.reconnectOnLaunch }))
}
ariaLabel={translate(
'auto.components.settings.SshTargetForm.reconnectOnLaunch',
'Reconnect on launch'
)}
/>
</div>
) : null}
<div className="flex items-start justify-between gap-4 py-1 text-xs">
<div className="min-w-0 flex-1 space-y-0.5">
<Label className="text-xs font-medium">
@@ -232,6 +232,7 @@ export function SshTargetForm({
</div>
<SshHostAdvancedFields
offerProxyConnectionReuse
offerReconnectOnLaunch
open={advancedOpen}
onOpenChange={setAdvancedOpen}
form={form}
@@ -14,6 +14,7 @@ export type EditingTarget = {
systemSshConnectionReuse: boolean
systemSshProxyConnectionReuse: boolean
allowRemoteCliControl: boolean
reconnectOnLaunch: boolean
}
export const EMPTY_FORM: EditingTarget = {
@@ -28,7 +29,8 @@ export const EMPTY_FORM: EditingTarget = {
jumpHost: '',
systemSshConnectionReuse: true,
systemSshProxyConnectionReuse: false,
allowRemoteCliControl: true
allowRemoteCliControl: true,
reconnectOnLaunch: true
}
export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget {
@@ -46,7 +48,8 @@ export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget {
jumpHost: target.jumpHost ?? '',
systemSshConnectionReuse: target.systemSshConnectionReuse !== false,
systemSshProxyConnectionReuse: target.systemSshProxyConnectionReuse === true,
allowRemoteCliControl: isRemoteCliControlAllowed(target)
allowRemoteCliControl: isRemoteCliControlAllowed(target),
reconnectOnLaunch: target.reconnectOnLaunch !== false
}
}
@@ -151,7 +154,8 @@ export function hasAdvancedConnectionValues(form: EditingTarget): boolean {
form.proxyCommand.trim().length > 0 ||
form.jumpHost.trim().length > 0 ||
!form.systemSshConnectionReuse ||
!form.allowRemoteCliControl
!form.allowRemoteCliControl ||
!form.reconnectOnLaunch
)
}
@@ -168,7 +172,8 @@ export function isSshTargetFormDirty(current: EditingTarget, baseline: EditingTa
current.jumpHost !== baseline.jumpHost ||
current.systemSshConnectionReuse !== baseline.systemSshConnectionReuse ||
current.systemSshProxyConnectionReuse !== baseline.systemSshProxyConnectionReuse ||
current.allowRemoteCliControl !== baseline.allowRemoteCliControl
current.allowRemoteCliControl !== baseline.allowRemoteCliControl ||
current.reconnectOnLaunch !== baseline.reconnectOnLaunch
)
}
@@ -38,6 +38,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay
const jumpHost = form.jumpHost.trim() || undefined
const systemSshConnectionReuse = form.systemSshConnectionReuse ? undefined : false
const systemSshProxyConnectionReuse = form.systemSshProxyConnectionReuse ? true : undefined
const reconnectOnLaunch = form.reconnectOnLaunch ? undefined : false
const target: SshTargetCreateInput = {
label: form.label.trim() || (username ? `${username}@${host}` : configHost),
@@ -51,6 +52,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay
...(jumpHost ? { jumpHost } : {}),
...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}),
...(systemSshProxyConnectionReuse ? { systemSshProxyConnectionReuse } : {}),
...(reconnectOnLaunch === false ? { reconnectOnLaunch } : {}),
// Send explicit true/false because older servers interpret absence as disabled.
allowRemoteCliControl: form.allowRemoteCliControl
}
@@ -68,6 +70,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay
jumpHost,
systemSshConnectionReuse,
systemSshProxyConnectionReuse,
reconnectOnLaunch,
source: 'manual'
}
}
@@ -1,5 +1,6 @@
import { useAppStore } from '@/store'
import { isRuntimeOwnedSshTargetId } from '../../../../../shared/execution-host'
import { isSshUserDisconnectedMessage } from '../../../../../shared/ssh-user-disconnect-message'
import { isSshConnectInFlight, trackSshConnect } from '@/ssh/ssh-connect-in-flight'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import type { PtyTransport } from '../pty-transport-types'
@@ -95,10 +96,13 @@ export function connectUnservedSshPaneTarget(
try {
await trackSshConnect(targetId, window.api.ssh.connect({ targetId, automatic: true }))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// A host held disconnected waits for Connect; the inert pane already says so.
if (isSshUserDisconnectedMessage(message)) {
return
}
console.warn(`[pty-connection] SSH connect failed for ${targetId}:`, err)
session.reportError(
`SSH connection failed: ${err instanceof Error ? err.message : String(err)}`
)
session.reportError(`SSH connection failed: ${message}`)
}
})()
}
+2
View File
@@ -9329,6 +9329,8 @@
"53e9aabfc0": "Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions.",
"proxyConnectionReuse": "Reuse one proxy connection",
"proxyConnectionReuseHelp": "Faster connects through slow ProxyCommand or ProxyJump hosts. Only enable if your ssh_config doesn't rely on host-specific Match or %h rules.",
"reconnectOnLaunch": "Reconnect on launch",
"reconnectOnLaunchHelp": "When off, Orca leaves this host, and any Orca server reached through it, disconnected at launch until you connect it.",
"allowRemoteCliControl": "Allow this host's orca CLI to control Orca",
"allowRemoteCliControlHelp": "On by default for new hosts. Lets orca commands run on this host open and drive Orca browser tabs on this desktop, only in this host's workspaces and only while it is connected, and lets terminals started by an older Orca on this host run orchestration commands (check, inbox, send, ask, reply) for their own supervised work. They cannot read your files, clipboard or cookies. When off, they reach only this host's own Orca server.",
"editTitle": "Edit SSH host",
+2
View File
@@ -8945,6 +8945,8 @@
"editDescription": "Actualiza los detalles de conexión de esta máquina. Los cambios se aplican en la próxima conexión.",
"addDescription": "Agrega una máquina persistente en la que puedas iniciar sesión por SSH.",
"editingPrefix": "Editando",
"reconnectOnLaunch": "Reconectar al iniciar",
"reconnectOnLaunchHelp": "Si está desactivado, Orca deja este host, y cualquier servidor de Orca al que se llegue a través de él, desconectado al iniciarse hasta que lo conectes.",
"allowRemoteCliControl": "Permitir que la orca CLI de este host controle Orca",
"allowRemoteCliControlHelp": "Activado de forma predeterminada en los hosts nuevos. Permite que los comandos orca ejecutados en este host abran y controlen pestañas del navegador de Orca en este escritorio, solo en los espacios de trabajo de este host y solo mientras esté conectado, y que las terminales iniciadas por una versión anterior de Orca en este host ejecuten comandos de orquestación (check, inbox, send, ask, reply) para su propio trabajo supervisado. No pueden leer tus archivos, tu portapapeles ni tus cookies. Cuando está desactivado, solo llegan al propio servidor de Orca de este host.",
"addHost": "Añadir host"
+2
View File
@@ -9127,6 +9127,8 @@
"editDescription": "Mettez à jour les détails de connexion de cette machine. Les modifications s'appliquent à la prochaine connexion.",
"addDescription": "Ajoutez une machine permanente sur laquelle vous pouvez vous connecter en SSH.",
"editingPrefix": "Modification",
"reconnectOnLaunch": "Se reconnecter au lancement",
"reconnectOnLaunchHelp": "Si désactivé, Orca laisse cet hôte, et tout serveur Orca joint par son intermédiaire, déconnecté au démarrage jusqu'à ce que vous le connectiez.",
"allowRemoteCliControl": "Autoriser l'orca CLI de cet hôte à contrôler Orca",
"allowRemoteCliControlHelp": "Activé par défaut pour les nouveaux hôtes. Permet aux commandes orca exécutées sur cet hôte d'ouvrir et de piloter des onglets du navigateur Orca sur ce bureau, uniquement dans les espaces de travail de cet hôte et uniquement tant qu'il est connecté, et aux terminaux lancés par une ancienne version d'Orca sur cet hôte d'exécuter des commandes d'orchestration (check, inbox, send, ask, reply) pour leur propre travail supervisé. Elles ne peuvent pas lire vos fichiers, votre presse-papiers ni vos cookies. Lorsque cette option est désactivée, elles n'atteignent que le serveur Orca de cet hôte.",
"addHost": "Ajouter un hôte"
+2
View File
@@ -8951,6 +8951,8 @@
"editDescription": "このマシンの接続詳細を更新します。変更は次回の接続時に適用されます。",
"addDescription": "SSH でログインできる永続的なマシンを追加します。",
"editingPrefix": "編集中",
"reconnectOnLaunch": "起動時に再接続",
"reconnectOnLaunchHelp": "オフにすると、Orca は起動時にこのホストと、それを経由する Orca サーバーを、接続するまで切断したままにします。",
"allowRemoteCliControl": "このホストの orca CLI による Orca の操作を許可する",
"allowRemoteCliControlHelp": "新しいホストではデフォルトでオンです。このホストで実行される orca コマンドが、このホストのワークスペース内に限り、接続中のみ、このデスクトップの Orca ブラウザのタブを開いて操作できるようにし、このホストで古い Orca が起動したターミナルが自身の監督下の作業についてオーケストレーションコマンド(check、inbox、send、ask、reply)を実行できるようにします。ファイル、クリップボード、Cookie は読み取れません。オフの場合、このホスト自身の Orca サーバーにのみアクセスします。",
"addHost": "ホストを追加"
+2
View File
@@ -8914,6 +8914,8 @@
"editDescription": "이 머신의 연결 세부 정보를 업데이트합니다. 변경 사항은 다음 연결 시 적용됩니다.",
"addDescription": "SSH로 로그인할 수 있는 영구 머신을 추가합니다.",
"editingPrefix": "편집 중",
"reconnectOnLaunch": "시작 시 다시 연결",
"reconnectOnLaunchHelp": "끄면 Orca가 시작될 때 이 호스트와 이 호스트를 통해 연결되는 Orca 서버를 직접 연결할 때까지 연결하지 않습니다.",
"allowRemoteCliControl": "이 호스트의 orca CLI가 Orca를 제어하도록 허용",
"allowRemoteCliControlHelp": "새 호스트에서는 기본적으로 켜져 있습니다. 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있게 하고, 이 호스트에서 이전 버전의 Orca가 시작한 터미널이 자신이 감독받는 작업에 대해 오케스트레이션 명령(check, inbox, send, ask, reply)을 실행할 수 있게 합니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다.",
"addHost": "호스트 추가"
+2
View File
@@ -5592,6 +5592,8 @@
"editDescription": "Atualize os detalhes de conexão desta máquina. As alterações se aplicam na próxima conexão.",
"addDescription": "Adicione uma máquina persistente na qual você pode entrar via SSH.",
"editingPrefix": "Editando",
"reconnectOnLaunch": "Reconectar ao iniciar",
"reconnectOnLaunchHelp": "Quando desativado, o Orca deixa este host, e qualquer servidor Orca acessado por ele, desconectado ao iniciar até você conectá-lo.",
"allowRemoteCliControl": "Permitir que a CLI orca deste host controle o Orca",
"allowRemoteCliControlHelp": "Ativado por padrão para novos hosts. Permite que comandos orca executados neste host abram e controlem abas do navegador Orca neste computador, apenas nos espaços de trabalho deste host e enquanto ele estiver conectado. Também permite que terminais iniciados por um Orca mais antigo neste host executem comandos de orquestração (check, inbox, send, ask, reply) para seu próprio trabalho supervisionado. Eles não podem ler seus arquivos, área de transferência ou cookies. Quando desativado, eles acessam apenas o próprio servidor Orca deste host.",
"addHost": "Adicionar host"
+2
View File
@@ -8914,6 +8914,8 @@
"editDescription": "更新此机器的连接详情。更改将在下次连接时生效。",
"addDescription": "添加一台可通过 SSH 登录的持久机器。",
"editingPrefix": "正在编辑",
"reconnectOnLaunch": "启动时重新连接",
"reconnectOnLaunchHelp": "关闭后,Orca 启动时会让此主机及通过它访问的 Orca 服务器保持断开,直到你手动连接。",
"allowRemoteCliControl": "允许此主机的 orca CLI 控制 Orca",
"allowRemoteCliControlHelp": "新主机默认开启。允许在此主机上运行的 orca 命令打开并操作此桌面上的 Orca 浏览器标签页,但仅限此主机的工作区,且仅在主机连接期间;并允许此主机上由旧版 Orca 启动的终端为其自身受监督的工作运行编排命令(check、inbox、send、ask、reply)。它们无法读取你的文件、剪贴板或 Cookie。关闭时,它们只能访问此主机自身的 Orca 服务器。",
"addHost": "添加主机"
@@ -88,6 +88,20 @@ describe('restoreSshConnectionsForStartup', () => {
])
})
it('leaves a host opted out of reconnect on launch disconnected', async () => {
installWindowApi([{ ...target('ssh-off'), reconnectOnLaunch: false }, target('ssh-on')])
harness.connect.mockImplementation(async (targetId: string) => connectedState(targetId))
await restoreSshConnectionsForStartup({
connectionIds: ['ssh-off', 'ssh-on'],
setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget,
publishSshConnectionState: harness.publishSshConnectionState
})
expect(harness.connect.mock.calls).toEqual([['ssh-on']])
})
it('does not wait on a target that owns no immediately-mounted pane', async () => {
vi.useFakeTimers()
installWindowApi([target('ssh-active'), target('ssh-asleep')])
@@ -24,10 +24,13 @@ export async function restoreSshConnectionsForStartup(args: {
window.api.ssh.listTargets()
)
const targetMap = new Map(allTargets.map((t) => [t.id, t]))
const targets = connectionIds.map((targetId) => ({
targetId,
needsPassphrase: targetMap.get(targetId)?.lastRequiredPassphrase ?? false
}))
// Opted-out hosts stay disconnected like hosts that were off at quit; their panes offer Connect.
const targets = connectionIds
.filter((targetId) => targetMap.get(targetId)?.reconnectOnLaunch !== false)
.map((targetId) => ({
targetId,
needsPassphrase: targetMap.get(targetId)?.lastRequiredPassphrase ?? false
}))
const passphraseTargetIds = targets.filter((t) => t.needsPassphrase).map((t) => t.targetId)
const blocking = blockingConnectionIds ? new Set(blockingConnectionIds) : null
+2
View File
@@ -62,6 +62,8 @@ export type SshTarget = {
allowRemoteCliControl?: boolean
/** Retired: written by earlier builds and ignored now. Kept so a downgrade still finds its stamp. */
allowRemoteCliControlConsent?: 'desktop-browser-bridge'
/** Undefined means on; false leaves this host disconnected at launch until the user connects it. */
reconnectOnLaunch?: boolean
/** Persist prompted-auth history so startup defers credential-requiring hosts without attempting a connection. */
lastRequiredPassphrase?: boolean
portForwards?: SavedPortForward[]
+10
View File
@@ -0,0 +1,10 @@
const SSH_USER_DISCONNECTED_SUFFIX = 'was disconnected. Connect to use it again.'
export function formatSshUserDisconnectedMessage(label: string): string {
return `${label} ${SSH_USER_DISCONNECTED_SUFFIX}`
}
/** IPC drops Error.code, so renderers recognize the held-disconnected refusal by its message. */
export function isSshUserDisconnectedMessage(message: string): boolean {
return message.endsWith(SSH_USER_DISCONNECTED_SUFFIX)
}