fix(settings): let an unfinished SSH access link be retried or cancelled (#29441)

* fix(settings): let an unfinished SSH access link be retried or cancelled

A failed link to a paired server keeps its durable pending operation so it
can resume, but the control only offered a new link with a fresh request
id, which main refuses, and no cancel. The pending state then blocked
environment rm and re-pairing with no way out. Show the unfinished
operation with Retry and Cancel, both sent under its saved request id, and
make the removal refusal say where to resolve it.

* test: match the pending SSH access refusal
This commit is contained in:
Neil
2026-10-15 23:23:21 -07:00
committed by GitHub
parent 8570b0a7d3
commit 15953ef0ea
10 changed files with 192 additions and 22 deletions
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
import { RuntimeSshAccessControl } from './RuntimeSshAccessControl'
function environment(
pendingSshAccessOperation: PublicKnownRuntimeEnvironment['pendingSshAccessOperation']
): PublicKnownRuntimeEnvironment {
return {
id: 'env-a',
name: 'fight PC',
createdAt: 1,
updatedAt: 1,
lastUsedAt: null,
runtimeId: 'runtime-a',
endpoints: [{ id: 'ws', kind: 'websocket', label: 'Tailscale', endpoint: 'ws://a:6768' }],
preferredEndpointId: 'ws',
pendingSshAccessOperation
}
}
const pendingLink = {
requestId: 'request-1',
operation: 'link' as const,
sshTargetId: 'ssh-weknora',
sshTargetGeneration: 3,
remotePort: 6768,
targetFingerprint: 'fingerprint'
}
function renderControl(env: PublicKnownRuntimeEnvironment) {
const linkSshAccess = vi.fn(async () => env)
const unlinkSshAccess = vi.fn(async () => env)
const onChanged = vi.fn()
const api: Pick<ManagedOrcadPreloadApi, 'linkSshAccess' | 'unlinkSshAccess'> = {
linkSshAccess,
unlinkSshAccess
}
render(<RuntimeSshAccessControl api={api} environment={env} targets={[]} onChanged={onChanged} />)
return { linkSshAccess, unlinkSshAccess, onChanged }
}
afterEach(cleanup)
// #27765: an unfinished link blocked removing the server and offered no way out.
describe('an unfinished SSH access link', () => {
it('cancels the recorded operation by its own request id', async () => {
const { unlinkSshAccess, onChanged } = renderControl(environment(pendingLink))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(onChanged).toHaveBeenCalled())
expect(unlinkSshAccess).toHaveBeenCalledWith({ selector: 'env-a', requestId: 'request-1' })
})
it('retries the recorded link rather than starting another', async () => {
const { linkSshAccess, onChanged } = renderControl(environment(pendingLink))
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(onChanged).toHaveBeenCalled())
expect(linkSshAccess).toHaveBeenCalledWith({
selector: 'env-a',
requestId: 'request-1',
sshTargetId: 'ssh-weknora',
remotePort: 6768
})
})
it('finishes an unfinished unlink by its own request id', async () => {
const { unlinkSshAccess, onChanged } = renderControl(
environment({ ...pendingLink, operation: 'unlink' })
)
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(onChanged).toHaveBeenCalled())
expect(unlinkSshAccess).toHaveBeenCalledWith({ selector: 'env-a', requestId: 'request-1' })
})
})
@@ -14,7 +14,7 @@ import { SshTargetSelect } from './SshTargetSelect'
const DEFAULT_REMOTE_PORT = '6768'
type RuntimeSshAccessControlProps = {
api: ManagedOrcadPreloadApi
api: Pick<ManagedOrcadPreloadApi, 'linkSshAccess' | 'unlinkSshAccess'>
environment: PublicKnownRuntimeEnvironment
targets: SshTarget[]
onChanged: () => void
@@ -33,19 +33,12 @@ export function RuntimeSshAccessControl({
const targetFieldId = useId()
const portFieldId = useId()
const linked = environment.sshAccess
const pending = environment.pendingSshAccessOperation
const submit = async (): Promise<void> => {
const run = async (call: () => Promise<unknown>): Promise<void> => {
setBusy(true)
try {
const requestId = createBrowserUuid()
await (linked
? api.unlinkSshAccess({ selector: environment.id, requestId })
: api.linkSshAccess({
selector: environment.id,
requestId,
sshTargetId: targetId,
remotePort: Number(remotePort)
}))
await call()
onChanged()
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
@@ -54,6 +47,75 @@ export function RuntimeSshAccessControl({
}
}
if (pending) {
// Why the saved request id: main resumes or cancels only the operation it recorded, and a
// fresh id made an unfinished link a dead end that also blocked removing the server (#27765).
const requestId = pending.requestId
const retry = (): Promise<unknown> =>
pending.operation === 'link'
? api.linkSshAccess({
selector: environment.id,
requestId,
sshTargetId: pending.sshTargetId,
remotePort: pending.remotePort
})
: api.unlinkSshAccess({ selector: environment.id, requestId })
return (
<div className="flex items-center gap-2 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{environment.name}</div>
<p className="text-xs text-muted-foreground">
{pending.operation === 'link'
? translate(
'auto.components.settings.managedServers.access.pendingLink',
'Connecting through SSH didn’t finish.'
)
: translate(
'auto.components.settings.managedServers.access.pendingUnlink',
'Stopping SSH access didn’t finish.'
)}
</p>
</div>
<Button
type="button"
size="xs"
variant="outline"
disabled={busy}
onClick={() => void run(retry)}
>
{translate('auto.components.settings.managedServers.access.retry', 'Retry')}
</Button>
{pending.operation === 'link' ? (
<Button
type="button"
size="xs"
variant="outline"
disabled={busy}
onClick={() =>
void run(() => api.unlinkSshAccess({ selector: environment.id, requestId }))
}
>
{translate('auto.components.settings.managedServers.access.cancelLink', 'Cancel')}
</Button>
) : null}
</div>
)
}
const submit = (): Promise<void> => {
const requestId = createBrowserUuid()
return run(() =>
linked
? api.unlinkSshAccess({ selector: environment.id, requestId })
: api.linkSshAccess({
selector: environment.id,
requestId,
sshTargetId: targetId,
remotePort: Number(remotePort)
})
)
}
if (linked) {
return (
<div className="flex items-center gap-2 px-4 py-3">
+5 -1
View File
@@ -12650,7 +12650,11 @@
"target": "Reach through SSH host",
"port": "Server port",
"link": "Use SSH",
"linking": "Connecting…"
"linking": "Connecting…",
"pendingLink": "Connecting through SSH didn’t finish.",
"pendingUnlink": "Stopping SSH access didn’t finish.",
"retry": "Retry",
"cancelLink": "Cancel"
},
"search": {
"title": "Managed servers",
+5 -1
View File
@@ -12461,7 +12461,11 @@
"target": "Conectar a través del host SSH",
"port": "Puerto del servidor",
"link": "Usar SSH",
"linking": "Conectando…"
"linking": "Conectando…",
"pendingLink": "La conexión por SSH no terminó.",
"pendingUnlink": "La desactivación del acceso SSH no terminó.",
"retry": "Reintentar",
"cancelLink": "Cancelar"
},
"search": {
"title": "Servidores gestionados",
+5 -1
View File
@@ -12573,7 +12573,11 @@
"target": "Passer par un hôte SSH",
"port": "Port du serveur",
"link": "Utiliser SSH",
"linking": "Connexion…"
"linking": "Connexion…",
"pendingLink": "La connexion via SSH n’a pas abouti.",
"pendingUnlink": "L’arrêt de l’accès SSH n’a pas abouti.",
"retry": "Réessayer",
"cancelLink": "Annuler"
},
"search": {
"title": "Serveurs gérés",
+5 -1
View File
@@ -12465,7 +12465,11 @@
"target": "SSH ホスト経由で接続",
"port": "サーバーポート",
"link": "SSH を使用",
"linking": "接続中…"
"linking": "接続中…",
"pendingLink": "SSH 経由の接続が完了しませんでした。",
"pendingUnlink": "SSH アクセスの停止が完了しませんでした。",
"retry": "再試行",
"cancelLink": "キャンセル"
},
"search": {
"title": "管理対象サーバー",
+5 -1
View File
@@ -12465,7 +12465,11 @@
"target": "SSH 호스트를 통해 연결",
"port": "서버 포트",
"link": "SSH 사용",
"linking": "연결 중…"
"linking": "연결 중…",
"pendingLink": "SSH를 통한 연결이 완료되지 않았습니다.",
"pendingUnlink": "SSH 액세스 중지가 완료되지 않았습니다.",
"retry": "다시 시도",
"cancelLink": "취소"
},
"search": {
"title": "관리형 서버",
+5 -1
View File
@@ -12479,7 +12479,11 @@
"target": "通过 SSH 主机访问",
"port": "服务器端口",
"link": "使用 SSH",
"linking": "正在连接…"
"linking": "正在连接…",
"pendingLink": "通过 SSH 连接未完成。",
"pendingUnlink": "停止 SSH 访问未完成。",
"retry": "重试",
"cancelLink": "取消"
},
"search": {
"title": "受管服务器",
@@ -220,12 +220,12 @@ describe('independent paired runtime SSH access persistence', () => {
expect(prepare(original)).toEqual(prepared)
expect(prepare(prepared)).toEqual(prepared)
expect(() => prepare(prepared, 'other-request')).toThrow('Another SSH access')
expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink')
expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Retry or cancel it')
expect(() =>
updateEnvironmentFromPairingCode(userDataPath, prepared.id, {
pairingCode: encodePairingOffer(pairing)
})
).toThrow('Unlink')
).toThrow('Retry or cancel it')
expect(() =>
markEnvironmentUsed(userDataPath, prepared.id, { runtimeId: 'different-runtime' })
).toThrow('cannot change')
@@ -288,7 +288,7 @@ describe('independent paired runtime SSH access persistence', () => {
requestId: 'stale-request'
})
).toThrow('pending unlink intent')
expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink')
expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Retry or cancel it')
const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, {
expectedEnvironment: prepared,
requestId: 'release-request'
+8 -2
View File
@@ -269,10 +269,16 @@ function assertNoIndependentSshAccess(environment: KnownRuntimeEnvironment): voi
'This server is managed by Orca over SSH; its pairing cannot be replaced or removed here. To decommission it, run `orca environment stop --environment <name> --yes` or use Settings > Managed servers; for a host that is gone, run `orca environment forget --environment <name> --yes`.'
)
}
if (environment.sshAccess || environment.pendingSshAccessOperation) {
if (environment.pendingSshAccessOperation) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
"Unlink this server's SSH access before replacing its pairing or removing it."
"This server's SSH access change didn't finish. Retry or cancel it under Settings > Remote Orca Servers > SSH access for paired servers before replacing its pairing or removing it."
)
}
if (environment.sshAccess) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
"Unlink this server's SSH access under Settings > Remote Orca Servers > SSH access for paired servers before replacing its pairing or removing it."
)
}
}