diff --git a/config/scripts/pr-e2e-route-contracts.test.mjs b/config/scripts/pr-e2e-route-contracts.test.mjs
index 8b03530139c..83d7124cc00 100644
--- a/config/scripts/pr-e2e-route-contracts.test.mjs
+++ b/config/scripts/pr-e2e-route-contracts.test.mjs
@@ -516,7 +516,11 @@ describe('managed-SSH remote sources routing', () => {
// #30047 and #30060: sidebar sleep and project removal.
'src/renderer/src/components/sidebar/sleep-worktree-flow.ts',
'src/renderer/src/components/sidebar/ssh-host-remove-resolution.ts',
- 'src/renderer/src/store/repos/repo-removal.ts'
+ 'src/renderer/src/store/repos/repo-removal.ts',
+ // #30775: the pane banner for a managed server that refused this desktop.
+ 'src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx',
+ 'src/renderer/src/components/terminal-pane/use-managed-pairing-refused.ts',
+ 'src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx'
]) {
expect(existsSync(join(projectDir, file)), file).toBe(true)
expect(selectsManagedSshLane(file), file).toBe(true)
diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs
index 156381499ce..c6d4057af4b 100644
--- a/config/scripts/pr-e2e-source-routing.mjs
+++ b/config/scripts/pr-e2e-source-routing.mjs
@@ -269,6 +269,7 @@ export const PR_E2E_SOURCE_ROUTES = [
id: 'ssh.orcad-remote-sources',
specs: [
'tests/e2e/ssh-orcad-paired-phone-projects.spec.ts',
+ 'tests/e2e/ssh-orcad-pairing-repair.spec.ts',
'tests/e2e/ssh-orcad-retained-upgrade-state.spec.ts',
'tests/e2e/ssh-orcad-shared-home-machines.spec.ts',
'tests/e2e/ssh-orcad-shared-login-migration.spec.ts',
@@ -277,7 +278,7 @@ export const PR_E2E_SOURCE_ROUTES = [
],
matches: (file) =>
isProductSource(file) &&
- /^src\/(?:main\/(?:ssh|orcad|managed-server)\/|main\/ipc\/(?:ssh|runtime-environment|orcad-)|cli\/runtime\/(?:launch-)?serve|shared\/(?:remote-runtime|orcad-|protocol-version\.ts$|runtime-capability)|renderer\/src\/store\/repos\/(?:repo-removal|runtime-project-terminal-stop)|renderer\/src\/components\/sidebar\/(?:sleep-|SleepTwin|WorkspaceSleep|use-worktree-sleep|ssh-host-remove|host-rename-remove|HostRemove))/.test(
+ /^src\/(?:main\/(?:ssh|orcad|managed-server)\/|main\/ipc\/(?:ssh|runtime-environment|orcad-)|cli\/runtime\/(?:launch-)?serve|shared\/(?:remote-runtime|orcad-|protocol-version\.ts$|runtime-capability)|renderer\/src\/store\/repos\/(?:repo-removal|runtime-project-terminal-stop)|renderer\/src\/components\/sidebar\/(?:sleep-|SleepTwin|WorkspaceSleep|use-worktree-sleep|ssh-host-remove|host-rename-remove|HostRemove)|renderer\/src\/components\/terminal-pane\/(?:TerminalRemoteRuntimeReconnectBanner|use-managed-pairing-refused|TerminalPaneRuntimePortals))/.test(
file
)
},
diff --git a/src/main/ipc/runtime-environments/orcad-delta-move-handlers.test.ts b/src/main/ipc/runtime-environments/orcad-delta-move-handlers.test.ts
index 685a5e5eca9..ec0843bb8de 100644
--- a/src/main/ipc/runtime-environments/orcad-delta-move-handlers.test.ts
+++ b/src/main/ipc/runtime-environments/orcad-delta-move-handlers.test.ts
@@ -5,6 +5,8 @@ const mocks = vi.hoisted(() => ({
move: vi.fn(),
keep: vi.fn(async () => {}),
publish: vi.fn(),
+ ensure: vi.fn(),
+ destinationFor: vi.fn((_environment: unknown) => ({})),
target: {
id: 'ssh-1',
label: 'Box',
@@ -30,10 +32,10 @@ vi.mock('../../ssh/orcad-migration-delta-move', () => ({
keepOrcadServerVersion: mocks.keep
}))
vi.mock('../../managed-server/lifecycle/orcad-runtime-conversion-wiring', () => ({
- orcadMigrationDestinationFor: () => ({})
+ orcadMigrationDestinationFor: mocks.destinationFor
}))
vi.mock('../../managed-server/lifecycle/orcad-managed-tunnel', () => ({
- ensureOrcadManagedTunnel: vi.fn()
+ ensureOrcadManagedTunnel: mocks.ensure
}))
vi.mock('../ssh/ssh-session-teardown', () => ({
disconnectRegisteredSshTarget: vi.fn()
@@ -58,6 +60,22 @@ describe('resolving a host an older build changed', () => {
registerOrcadDeltaMoveHandlers(() => '/tmp/user-data')
})
+ it('builds the destination from the pairing the tunnel stands on after opening', async () => {
+ // The tunnel re-paired over SSH while it opened.
+ const repaired = { id: 'env-1', pairingRevision: 2 }
+ mocks.ensure.mockResolvedValue(repaired)
+ const destination = { committed: true }
+ mocks.destinationFor.mockReturnValue(destination)
+ mocks.move.mockImplementation(async (args: { connectDestination: () => Promise }) => {
+ await expect(args.connectDestination()).resolves.toBe(destination)
+ return { outcome: 'moved', migrationId: 'm-1' }
+ })
+ await expect(
+ handler('runtimeEnvironments:moveOrcadDelta')(null, { sshTargetId: 'ssh-1' })
+ ).resolves.toMatchObject({ outcome: 'moved' })
+ expect(mocks.destinationFor).toHaveBeenCalledExactlyOnceWith(repaired)
+ })
+
it('refreshes the host status once the move lands, and not when it is refused', async () => {
mocks.move.mockResolvedValueOnce({ outcome: 'refused', code: 'x', reason: 'y' })
await handler('runtimeEnvironments:moveOrcadDelta')(null, { sshTargetId: 'ssh-1' })
diff --git a/src/main/ipc/runtime-environments/orcad-delta-move-handlers.ts b/src/main/ipc/runtime-environments/orcad-delta-move-handlers.ts
index 32258e76f33..ee1b393a320 100644
--- a/src/main/ipc/runtime-environments/orcad-delta-move-handlers.ts
+++ b/src/main/ipc/runtime-environments/orcad-delta-move-handlers.ts
@@ -42,11 +42,11 @@ export function registerOrcadDeltaMoveHandlers(getUserDataPath: () => string): v
claims,
target,
environment,
- destination: orcadMigrationDestinationFor(environment),
+ connectDestination: async () =>
+ orcadMigrationDestinationFor(
+ await ensureOrcadManagedTunnel(userDataPath, environment.id)
+ ),
censusHost: censusHostRelayTerminalsFor(target),
- ensureTunnel: async () => {
- await ensureOrcadManagedTunnel(userDataPath, environment.id)
- },
runTargetLifecycle
})
if (result.outcome === 'moved') {
diff --git a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts
index 432f43bb601..0dff59ec0b4 100644
--- a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts
+++ b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts
@@ -34,6 +34,7 @@ import {
} from './runtime-environment-manual-disconnect'
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe'
+import { noteRuntimeEnvironmentConnectRequested } from './runtime-environment-pairing-refusal-registry'
import { publicRuntimeEnvironmentWithHostKey } from './runtime-environment-host-key'
import { withWslOrcadAccountEnv } from '../../wsl/wsl-orcad-account-env'
import { clearSshCredentialDeclinedForEnvironment } from '../../ssh/ssh-credential-decline'
@@ -165,6 +166,7 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
const environment = resolveEnvironment(getUserDataPath(), args.selector)
assertWslHostReachable(environment)
clearRuntimeEnvironmentManualDisconnect(environment.id)
+ noteRuntimeEnvironmentConnectRequested(environment.id)
clearSshCredentialDeclinedForEnvironment(environment)
await liftSshUserDisconnectForEnvironment(environment)
await connectThroughOwningService(environment)
diff --git a/src/main/ipc/runtime-environments/runtime-environment-managed-pairing-refusal.test.ts b/src/main/ipc/runtime-environments/runtime-environment-managed-pairing-refusal.test.ts
new file mode 100644
index 00000000000..ba701e6b5d1
--- /dev/null
+++ b/src/main/ipc/runtime-environments/runtime-environment-managed-pairing-refusal.test.ts
@@ -0,0 +1,110 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { OrcadManagedPairingRepairFailedError } from '../../managed-server/lifecycle/orcad-managed-pairing-repair-failure'
+
+const mocks = vi.hoisted(() => ({
+ resolveEnvironment: vi.fn(),
+ authenticationRejected: vi.fn()
+}))
+vi.mock('../../../shared/runtime-environment-store', () => ({
+ resolveEnvironment: mocks.resolveEnvironment
+}))
+vi.mock('../../managed-server/lifecycle/orcad-managed-tunnel', () => ({
+ recoverRefusedManagedOrcadPairing: vi.fn(),
+ managedOrcadPairingFailureStands: vi.fn()
+}))
+vi.mock('./runtime-environment-request-connections', () => ({
+ getRuntimeEnvironmentStatusOwner: () => ({
+ authenticationRejected: mocks.authenticationRejected
+ })
+}))
+
+import { handleManagedSharedControlRefusal } from './runtime-environment-managed-pairing-refusal'
+
+const SSH_MANAGED = {
+ id: 'env-1',
+ orcadDeployment: { sshTargetId: 'box', sshTargetGeneration: 1, localPort: 1, remotePort: 2 }
+}
+
+describe('handleManagedSharedControlRefusal', () => {
+ beforeEach(() => {
+ mocks.resolveEnvironment.mockReset()
+ mocks.authenticationRejected.mockReset()
+ })
+
+ it.each([
+ ['a user-paired server', { id: 'env-1' }],
+ ['a WSL server', { id: 'env-1', orcadDeployment: { kind: 'wsl', distro: 'Ubuntu' } }]
+ ])('leaves %s to the usual reconnect', (_name, environment) => {
+ mocks.resolveEnvironment.mockReturnValue(environment)
+ const recover = vi.fn()
+ expect(handleManagedSharedControlRefusal('/profile', 'env-1', recover)).toBeNull()
+ expect(recover).not.toHaveBeenCalled()
+ })
+
+ it('stays closed once the re-pair replaced the socket', async () => {
+ mocks.resolveEnvironment.mockReturnValue(SSH_MANAGED)
+ const recover = vi.fn().mockResolvedValue('repaired')
+ await expect(handleManagedSharedControlRefusal('/profile', 'env-1', recover)).resolves.toBe(
+ 'stop'
+ )
+ expect(recover).toHaveBeenCalledWith('/profile', 'env-1')
+ })
+
+ it('retries with backoff when the server turned out to accept the pairing', async () => {
+ mocks.resolveEnvironment.mockReturnValue(SSH_MANAGED)
+ const recover = vi.fn().mockResolvedValue('built')
+ await expect(handleManagedSharedControlRefusal('/profile', 'env-1', recover)).resolves.toBe(
+ 'retry'
+ )
+ })
+
+ it('stops with the reason on the server status when pairing again failed', async () => {
+ mocks.resolveEnvironment.mockReturnValue(SSH_MANAGED)
+ const failure = new OrcadManagedPairingRepairFailedError(
+ 'the server refused the new pairing too'
+ )
+ const recover = vi.fn().mockRejectedValue(failure)
+ await expect(
+ handleManagedSharedControlRefusal('/profile', 'env-1', recover, () => true)
+ ).resolves.toBe('stop')
+ expect(mocks.authenticationRejected).toHaveBeenCalledWith(failure.message)
+ })
+
+ it('leaves a newer pairing stored meanwhile to its own owner', async () => {
+ mocks.resolveEnvironment.mockReturnValue(SSH_MANAGED)
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const recover = vi
+ .fn()
+ .mockRejectedValue(new OrcadManagedPairingRepairFailedError('the server refused it'))
+ // The failure stands for the old pairing only; an update stored a newer one meanwhile.
+ await expect(
+ handleManagedSharedControlRefusal('/profile', 'env-1', recover, () => false)
+ ).resolves.toBe('retry')
+ warn.mockRestore()
+ expect(mocks.authenticationRejected).not.toHaveBeenCalled()
+ })
+
+ it('settles quietly when the server was removed while it re-paired', async () => {
+ mocks.resolveEnvironment.mockReturnValue(SSH_MANAGED)
+ mocks.authenticationRejected.mockImplementation(() => {
+ throw new Error('Unknown runtime environment')
+ })
+ const recover = vi
+ .fn()
+ .mockRejectedValue(new OrcadManagedPairingRepairFailedError('the server refused it'))
+ await expect(
+ handleManagedSharedControlRefusal('/profile', 'env-1', recover, () => true)
+ ).resolves.toBe('stop')
+ })
+
+ it('keeps retrying after lost contact, which is no verdict on the pairing', async () => {
+ mocks.resolveEnvironment.mockReturnValue(SSH_MANAGED)
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const recover = vi.fn().mockRejectedValue(new Error('ssh: connection reset'))
+ await expect(handleManagedSharedControlRefusal('/profile', 'env-1', recover)).resolves.toBe(
+ 'retry'
+ )
+ warn.mockRestore()
+ expect(mocks.authenticationRejected).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/ipc/runtime-environments/runtime-environment-managed-pairing-refusal.ts b/src/main/ipc/runtime-environments/runtime-environment-managed-pairing-refusal.ts
new file mode 100644
index 00000000000..402970fd4f0
--- /dev/null
+++ b/src/main/ipc/runtime-environments/runtime-environment-managed-pairing-refusal.ts
@@ -0,0 +1,66 @@
+/** A server managed over SSH refused this desktop's token: pair again over SSH, or say why not. */
+import { errorMessage } from '../../../shared/error-message'
+import { resolveEnvironment } from '../../../shared/runtime-environment-store'
+import { getOrcadSshDeployment } from '../../../shared/runtime-environments'
+import { OrcadManagedPairingRepairFailedError } from '../../managed-server/lifecycle/orcad-managed-pairing-repair-failure'
+import {
+ managedOrcadPairingFailureStands,
+ recoverRefusedManagedOrcadPairing
+} from '../../managed-server/lifecycle/orcad-managed-tunnel'
+import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections'
+
+/** Null for anything but a server managed over SSH; other servers are paired by their user. */
+export function recoverRefusedManagedPairing(
+ userDataPath: string,
+ environmentId: string,
+ recover: typeof recoverRefusedManagedOrcadPairing = recoverRefusedManagedOrcadPairing
+): Promise<'built' | 'repaired'> | null {
+ try {
+ if (!getOrcadSshDeployment(resolveEnvironment(userDataPath, environmentId))) {
+ return null
+ }
+ } catch {
+ return null
+ }
+ return recover(userDataPath, environmentId)
+}
+
+/** Null keeps the usual reconnect; otherwise whether the refused socket should retry. */
+export function handleManagedSharedControlRefusal(
+ userDataPath: string,
+ environmentId: string,
+ recover: typeof recoverRefusedManagedOrcadPairing = recoverRefusedManagedOrcadPairing,
+ failureStands: typeof managedOrcadPairingFailureStands = managedOrcadPairingFailureStands
+): Promise<'retry' | 'stop'> | null {
+ const recovery = recoverRefusedManagedPairing(userDataPath, environmentId, recover)
+ if (!recovery) {
+ return null
+ }
+ return recovery.then(
+ // A re-pair retired this socket for one on the new pairing; otherwise retry with backoff.
+ (outcome) => (outcome === 'repaired' ? 'stop' : 'retry'),
+ (error: unknown) => {
+ // Why fenced: a newer pairing stored meanwhile has a successor owner this failure never judged.
+ if (
+ error instanceof OrcadManagedPairingRepairFailedError &&
+ failureStands(userDataPath, environmentId)
+ ) {
+ // Why stop: the verdict stands until the user connects again.
+ try {
+ getRuntimeEnvironmentStatusOwner(userDataPath, environmentId).authenticationRejected(
+ error.message
+ )
+ } catch {
+ // Removed while it re-paired: nothing is left to report on.
+ }
+ return 'stop'
+ }
+ // Lost contact or a foreign answer is not a verdict on the pairing.
+ console.warn(
+ '[runtime-environments] Could not re-pair the managed Orca server:',
+ errorMessage(error)
+ )
+ return 'retry'
+ }
+ )
+}
diff --git a/src/main/ipc/runtime-environments/runtime-environment-pairing-refusal-registry.ts b/src/main/ipc/runtime-environments/runtime-environment-pairing-refusal-registry.ts
new file mode 100644
index 00000000000..8f6e4a7784a
--- /dev/null
+++ b/src/main/ipc/runtime-environments/runtime-environment-pairing-refusal-registry.ts
@@ -0,0 +1,39 @@
+/**
+ * Who answers a server refusing this desktop's token, registered by the IPC layer so the status
+ * owner and the shared control socket reach it without importing the SSH tunnel machinery.
+ */
+export type RefusedPairingRecovery = (environmentId: string) => Promise<'built' | 'repaired'> | null
+export type SharedControlRefusalHandler = (
+ environmentId: string
+) => Promise<'retry' | 'stop'> | null
+
+let recovery: RefusedPairingRecovery = () => null
+let sharedControlHandler: SharedControlRefusalHandler = () => null
+let connectRequested: (environmentId: string) => void = () => {}
+
+export function setRefusedPairingHandlers(handlers: {
+ recover: RefusedPairingRecovery
+ sharedControl: SharedControlRefusalHandler
+ connectRequested: (environmentId: string) => void
+}): void {
+ recovery = handlers.recover
+ sharedControlHandler = handlers.sharedControl
+ connectRequested = handlers.connectRequested
+}
+
+/** An explicit Connect is the user asking again, so a recently failed re-pair must not answer it. */
+export function noteRuntimeEnvironmentConnectRequested(environmentId: string): void {
+ connectRequested(environmentId)
+}
+
+/** Null when nobody can pair this server again, so the refusal stands. */
+export function recoverRefusedPairing(environmentId: string): Promise<'built' | 'repaired'> | null {
+ return recovery(environmentId)
+}
+
+/** A promise means the handler owns recovery of the refused socket. */
+export function handleSharedControlRefusal(
+ environmentId: string
+): Promise<'retry' | 'stop'> | null {
+ return sharedControlHandler(environmentId)
+}
diff --git a/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts
index 7ef61ee0321..e866d2a3b25 100644
--- a/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts
+++ b/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts
@@ -16,9 +16,9 @@ import type {
RemoteRuntimeSharedConnectionDiagnostics,
RemoteRuntimeSharedSubscription
} from '../../../shared/remote-runtime-shared-control-types'
-import { isRuntimeEnvironmentCapabilityPaused } from './runtime-environment-capability-evidence'
import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect'
import { publishRuntimeEnvironmentDiagnostics } from './runtime-environment-diagnostics-broadcast'
+import { sharedControlEnvironmentPolicy } from './runtime-environment-shared-control-policy'
import {
clearSharedControlAnswerSource,
setSharedControlAnswerSource
@@ -283,8 +283,7 @@ function getSharedControlConnection(
connection: new RemoteRuntimeSharedControlConnection(pairedConnection, {
environmentId,
clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
- isManuallyDisconnected: () => isRuntimeEnvironmentManuallyDisconnected(environmentId),
- isCapabilityPaused: () => isRuntimeEnvironmentCapabilityPaused(environmentId),
+ ...sharedControlEnvironmentPolicy(environmentId),
// Why wall clock: it advances across a suspend, so a pre-sleep answer reads as stale.
onAnswered: () => {
answeredAt = Date.now()
diff --git a/src/main/ipc/runtime-environments/runtime-environment-shared-control-policy.ts b/src/main/ipc/runtime-environments/runtime-environment-shared-control-policy.ts
new file mode 100644
index 00000000000..0d22496d227
--- /dev/null
+++ b/src/main/ipc/runtime-environments/runtime-environment-shared-control-policy.ts
@@ -0,0 +1,18 @@
+import type { RemoteRuntimeSharedControlConnectionOptions } from '../../../shared/remote-runtime-shared-control-types'
+import { isRuntimeEnvironmentCapabilityPaused } from './runtime-environment-capability-evidence'
+import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect'
+import { handleSharedControlRefusal } from './runtime-environment-pairing-refusal-registry'
+
+/** When an environment's shared control socket may reconnect, and who answers a refused token. */
+export function sharedControlEnvironmentPolicy(
+ environmentId: string
+): Pick<
+ RemoteRuntimeSharedControlConnectionOptions,
+ 'isManuallyDisconnected' | 'isCapabilityPaused' | 'onAuthenticationRejected'
+> {
+ return {
+ isManuallyDisconnected: () => isRuntimeEnvironmentManuallyDisconnected(environmentId),
+ isCapabilityPaused: () => isRuntimeEnvironmentCapabilityPaused(environmentId),
+ onAuthenticationRejected: () => handleSharedControlRefusal(environmentId)
+ }
+}
diff --git a/src/main/ipc/runtime-environments/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environments/runtime-environment-status-owner.ts
index dd5fa68e704..353804ca516 100644
--- a/src/main/ipc/runtime-environments/runtime-environment-status-owner.ts
+++ b/src/main/ipc/runtime-environments/runtime-environment-status-owner.ts
@@ -6,11 +6,15 @@ import {
getPreferredPairedRuntimeConnection,
type KnownRuntimeEnvironment
} from '../../../shared/runtime-environments'
+import { OrcadManagedPairingRepairFailedError } from '../../managed-server/lifecycle/orcad-managed-pairing-repair-failure'
+import { recoverRefusedPairing } from './runtime-environment-pairing-refusal-registry'
import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record'
import { RuntimeHostStatusOwner } from '../../../shared/runtime-host-status-owner'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import {
RUNTIME_HOST_STATUS_CHANNEL,
+ runtimeHostStatusError,
+ runtimeHostStatusFailure,
type RuntimeHostStatusResponse
} from '../../../shared/runtime-host-status'
import {
@@ -31,6 +35,31 @@ export function onRuntimeEnvironmentStatusPublished(listener: () => void): () =>
return () => statusListeners.delete(listener)
}
+/**
+ * A managed server is paired over SSH, so its refusal re-pairs there instead of blocking on a
+ * re-pair only the user could do. A failed re-pair keeps the block, with the reason it failed.
+ */
+async function answerRefusedPairing(
+ environment: KnownRuntimeEnvironment,
+ refused: RuntimeHostStatusResponse
+): Promise {
+ const recovery = recoverRefusedPairing(environment.id)
+ if (!recovery) {
+ return refused
+ }
+ let outcome: 'built' | 'repaired'
+ try {
+ outcome = await recovery
+ } catch (error) {
+ return error instanceof OrcadManagedPairingRepairFailedError
+ ? runtimeHostStatusFailure('unauthorized', error.message)
+ : runtimeHostStatusError(error)
+ }
+ // Why changed, not the refusal: the rebuild re-proved the server, so this answer is stale; only
+ // a re-pair changed the pairing (and retired this owner for a successor that verifies anew).
+ return runtimeEnvironmentChangedFailure(environment, 'status.get', outcome === 'repaired')
+}
+
export function createRuntimeEnvironmentStatusOwner(
userDataPath: string,
environment: KnownRuntimeEnvironment,
@@ -51,27 +80,47 @@ export function createRuntimeEnvironmentStatusOwner(
const isCurrent = (): boolean =>
getRuntimeEnvironmentCapabilityIncarnation(environment.id) === incarnation
if (environment.connectionDependency === 'ssh-tunnel') {
- await resolveManagedRuntimeEnvironment(userDataPath, environment.id)
+ try {
+ await resolveManagedRuntimeEnvironment(userDataPath, environment.id)
+ } catch (error) {
+ if (error instanceof OrcadManagedPairingRepairFailedError) {
+ return runtimeHostStatusFailure('unauthorized', error.message)
+ }
+ throw error
+ }
if (!isCurrent()) {
return runtimeEnvironmentChangedFailure(environment, 'status.get')
}
signal.throwIfAborted()
}
evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, connection)
- const response = await (transport.isReady() &&
- getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, connection, null)?.kind ===
- 'supported'
- ? transport.request(signal)
- : sendRemoteRuntimeRequest(
- connection,
- 'status.get',
- undefined,
- 15_000,
- undefined,
- signal,
- ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
- ))
- return isCurrent() ? response : runtimeEnvironmentChangedFailure(environment, 'status.get')
+ let response: RuntimeHostStatusResponse
+ try {
+ response = await (transport.isReady() &&
+ getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, connection, null)?.kind ===
+ 'supported'
+ ? transport.request(signal)
+ : sendRemoteRuntimeRequest(
+ connection,
+ 'status.get',
+ undefined,
+ 15_000,
+ undefined,
+ signal,
+ ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
+ ))
+ } catch (error) {
+ response = runtimeHostStatusError(error)
+ if (response.ok || response.error.code !== 'unauthorized') {
+ throw error
+ }
+ }
+ if (!isCurrent()) {
+ return runtimeEnvironmentChangedFailure(environment, 'status.get')
+ }
+ return !response.ok && response.error.code === 'unauthorized'
+ ? answerRefusedPairing(environment, response)
+ : response
},
verified: (response, active) => {
const capable =
diff --git a/src/main/ipc/runtime-environments/runtime-environment-status-probe.ts b/src/main/ipc/runtime-environments/runtime-environment-status-probe.ts
index c91c0297bed..c4d31043aa9 100644
--- a/src/main/ipc/runtime-environments/runtime-environment-status-probe.ts
+++ b/src/main/ipc/runtime-environments/runtime-environment-status-probe.ts
@@ -1,5 +1,10 @@
import { resolveEnvironment } from '../../../shared/runtime-environment-store'
-import { getPreferredPairedRuntimeConnection } from '../../../shared/runtime-environments'
+import {
+ getOrcadSshDeployment,
+ getPreferredPairedRuntimeConnection,
+ getPreferredPairingOffer,
+ type KnownRuntimeEnvironment
+} from '../../../shared/runtime-environments'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import { getRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence'
@@ -13,7 +18,9 @@ export async function getRuntimeEnvironmentStatus(
userDataPath: string,
selector: string,
timeoutMs?: number,
- options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true }
+ options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true },
+ /** False for a caller pinned to this pairing revision, which a re-pair must fail instead. */
+ followRepair = true
): Promise> {
const environment = resolveEnvironment(userDataPath, selector)
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
@@ -33,7 +40,10 @@ export async function getRuntimeEnvironmentStatus(
})
// A retired request must not publish old status or borrow its replacement's diagnostics.
if (getRuntimeEnvironmentCapabilityIncarnation(environment.id) !== incarnation) {
- return runtimeEnvironmentChangedFailure(environment, 'status.get')
+ // Why follow once: a re-pair this probe set off retired its owner; the successor answers it.
+ return followRepair && repairedSameServer(userDataPath, environment)
+ ? getRuntimeEnvironmentStatus(userDataPath, environment.id, timeoutMs, options, false)
+ : runtimeEnvironmentChangedFailure(environment, 'status.get')
}
return attachRemoteControlDiagnostics(
withTailscaleHintForResponse(
@@ -44,3 +54,21 @@ export async function getRuntimeEnvironmentStatus(
environment.id
)
}
+
+/**
+ * A new pairing for the same server managed over SSH, under the host key it pinned: what a re-pair
+ * stores. Any other replacement (another host, key or kind) stays a changed environment.
+ */
+function repairedSameServer(userDataPath: string, before: KnownRuntimeEnvironment): boolean {
+ try {
+ const after = resolveEnvironment(userDataPath, before.id)
+ return (
+ (after.pairingRevision ?? after.createdAt) !== (before.pairingRevision ?? before.createdAt) &&
+ getOrcadSshDeployment(before) !== undefined &&
+ getOrcadSshDeployment(after) !== undefined &&
+ getPreferredPairingOffer(after).publicKeyB64 === getPreferredPairingOffer(before).publicKeyB64
+ )
+ } catch {
+ return false
+ }
+}
diff --git a/src/main/ipc/runtime-environments/runtime-environment-status-retirement.test.ts b/src/main/ipc/runtime-environments/runtime-environment-status-retirement.test.ts
index 06fcf7fd958..45e2313d2b0 100644
--- a/src/main/ipc/runtime-environments/runtime-environment-status-retirement.test.ts
+++ b/src/main/ipc/runtime-environments/runtime-environment-status-retirement.test.ts
@@ -2,10 +2,11 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { resolveEnvironment } from '../../../shared/runtime-environment-store'
import {
- addEnvironmentFromPairingCode,
- resolveEnvironment
-} from '../../../shared/runtime-environment-store'
+ addManagedOrcadEnvironment,
+ refreshManagedOrcadPairing
+} from '../../../shared/runtime-environment-managed-orcad-store'
import { pairingCode } from './runtime-environments-ipc-test-harness'
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../shared/protocol-version'
import {
@@ -33,9 +34,14 @@ vi.mock('./runtime-environment-request-connections', async () => {
})
})
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
+// Why: the tunnel is standing; these cases are about what answers through it.
+vi.mock('./runtime-environment-managed-tunnel', () => ({
+ resolveManagedRuntimeEnvironment: vi.fn(async () => undefined)
+}))
import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe'
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
+import { setRefusedPairingHandlers } from './runtime-environment-pairing-refusal-registry'
let directory: string
let envId: string
@@ -43,11 +49,17 @@ beforeEach(() => {
vi.clearAllMocks()
resetRuntimeEnvironmentCapabilityEvidence()
directory = mkdtempSync(join(tmpdir(), 'orca-status-retirement-'))
- envId = addEnvironmentFromPairingCode(directory, {
+ envId = addManagedOrcadEnvironment(directory, {
+ id: 'env-managed',
name: 'Host',
now: 1,
pairingCode: pairingCode(),
- connectionDependency: 'ssh-tunnel'
+ orcadDeployment: {
+ sshTargetId: 'box',
+ sshTargetGeneration: 1,
+ localPort: 6768,
+ remotePort: 6768
+ }
}).id
mocks.diagnostics.mockReturnValue(null)
})
@@ -110,3 +122,77 @@ it('does not fence another environment or a fresh probe after retirement', async
expect(resolveEnvironment(directory, envId).runtimeId).toBe('old-host')
expect(mocks.ensure).toHaveBeenCalledTimes(2)
})
+
+/** What a re-pair over SSH does mid-probe: store a new pairing and retire the old transports. */
+function repairMidProbe(now: number, keyByte = 1): void {
+ refreshManagedOrcadPairing(
+ directory,
+ envId,
+ pairingCode(undefined, { deviceToken: `token-${now}`, keyByte }),
+ now
+ )
+ advanceRuntimeEnvironmentCapabilityIncarnation(envId)
+}
+
+it('answers from the successor owner when the probe itself re-paired the server', async () => {
+ mocks.request.mockImplementationOnce(async () => {
+ repairMidProbe(2)
+ return {
+ id: 'status',
+ ok: false,
+ error: { code: 'unauthorized', message: 'refused' },
+ _meta: { runtimeId: 'old-host' }
+ }
+ })
+ mocks.request.mockResolvedValueOnce(response())
+ await expect(
+ getRuntimeEnvironmentStatus(directory, envId, undefined, { reconnect: true })
+ ).resolves.toMatchObject({ ok: true })
+ expect(mocks.request).toHaveBeenCalledTimes(2)
+})
+
+it.each([
+ ['a pairing under another host key', () => repairMidProbe(2, 2), true],
+ ['a caller pinned to the old pairing', () => repairMidProbe(2), false]
+] as const)('never answers %s from the replacement', async (_name, replace, follow) => {
+ mocks.request.mockImplementationOnce(async () => {
+ replace()
+ return response()
+ })
+ mocks.request.mockResolvedValue(response())
+ await expect(
+ getRuntimeEnvironmentStatus(directory, envId, undefined, undefined, follow)
+ ).resolves.toMatchObject({ ok: false, error: { code: 'runtime_environment_changed' } })
+ expect(mocks.request).toHaveBeenCalledOnce()
+})
+
+it('follows a re-pair only once', async () => {
+ let now = 2
+ mocks.request.mockImplementation(async () => {
+ repairMidProbe(now++)
+ return response()
+ })
+ await expect(getRuntimeEnvironmentStatus(directory, envId)).resolves.toMatchObject({
+ ok: false,
+ error: { code: 'runtime_environment_changed' }
+ })
+ expect(mocks.request).toHaveBeenCalledTimes(2)
+})
+
+it('does not claim the pairing changed when the refusal only rebuilt the tunnel', async () => {
+ const noRecovery = { recover: () => null, sharedControl: () => null, connectRequested: () => {} }
+ setRefusedPairingHandlers({ ...noRecovery, recover: async () => 'built' as const })
+ mocks.request.mockResolvedValueOnce({
+ id: 'status',
+ ok: false,
+ error: { code: 'unauthorized', message: 'refused' },
+ _meta: { runtimeId: 'old-host' }
+ })
+ try {
+ const answer = await getRuntimeEnvironmentStatus(directory, envId)
+ expect(answer).toMatchObject({ ok: false, error: { code: 'runtime_environment_changed' } })
+ expect(JSON.stringify(answer)).not.toMatch(/pairing changed/)
+ } finally {
+ setRefusedPairingHandlers(noRecovery)
+ }
+})
diff --git a/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts
index 6c156c075c7..26721573ddf 100644
--- a/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts
+++ b/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts
@@ -47,7 +47,16 @@ export async function callRuntimeEnvironment(
expectedEnvironmentPairingRevision,
method
)
- return failure ?? getRuntimeEnvironmentStatus(userDataPath, selector, timeoutMs, options)
+ return (
+ failure ??
+ getRuntimeEnvironmentStatus(
+ userDataPath,
+ selector,
+ timeoutMs,
+ options,
+ expectedEnvironmentPairingRevision === undefined
+ )
+ )
}
const environment = resolveEnvironment(userDataPath, selector)
// Why: connection failures reject (they don't resolve as ok:false), so the
diff --git a/src/main/ipc/runtime-environments/runtime-environments.ts b/src/main/ipc/runtime-environments/runtime-environments.ts
index eb0438421ef..e5f66c324c2 100644
--- a/src/main/ipc/runtime-environments/runtime-environments.ts
+++ b/src/main/ipc/runtime-environments/runtime-environments.ts
@@ -16,6 +16,13 @@ import {
closeRemoteRuntimeRequestConnection,
getRuntimeEnvironmentStatusOwner
} from './runtime-environment-request-connections'
+import { setRefusedPairingHandlers } from './runtime-environment-pairing-refusal-registry'
+import {
+ handleManagedSharedControlRefusal,
+ recoverRefusedManagedPairing
+} from './runtime-environment-managed-pairing-refusal'
+import { setManagedOrcadPairingRepairedListener } from '../../managed-server/lifecycle/orcad-managed-pairing-repaired-listener'
+import { forgetFailedManagedOrcadPairingRepair } from '../../managed-server/lifecycle/orcad-managed-tunnel'
import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler'
import { registerRuntimeEnvironmentRepairHandler } from './runtime-environment-repair-handler'
import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation'
@@ -143,6 +150,14 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
invalidateTransport: invalidateRuntimeEnvironmentTransport
})
registerRuntimeEnvironmentPassiveHandlers(getUserDataPath)
+ setManagedOrcadPairingRepairedListener(invalidateRuntimeEnvironmentTransport)
+ setRefusedPairingHandlers({
+ recover: (environmentId) => recoverRefusedManagedPairing(getUserDataPath(), environmentId),
+ sharedControl: (environmentId) =>
+ handleManagedSharedControlRefusal(getUserDataPath(), environmentId),
+ connectRequested: (environmentId) =>
+ forgetFailedManagedOrcadPairingRepair(getUserDataPath(), environmentId)
+ })
for (const environment of listEnvironments(getUserDataPath())) {
if (
!isRuntimeEnvironmentManuallyDisconnected(environment.id) &&
diff --git a/src/main/ipc/ssh/ssh-connect-flow.ts b/src/main/ipc/ssh/ssh-connect-flow.ts
index 095e1f201f8..8421028822c 100644
--- a/src/main/ipc/ssh/ssh-connect-flow.ts
+++ b/src/main/ipc/ssh/ssh-connect-flow.ts
@@ -11,6 +11,8 @@ import {
runAttributedToSshOwner
} from '../../ssh/connection/ssh-connection-attribution'
import { getSshTargetRegistryStore } from '../../ssh/targets/ssh-target-registry'
+import { getManagedOrcadFenceEnvironmentId } from '../../../shared/managed-orcad-ssh-owner'
+import { noteRuntimeEnvironmentConnectRequested } from '../runtime-environments/runtime-environment-pairing-refusal-registry'
import {
decideHostServer,
recheckWhenManagedFenceClears,
@@ -57,6 +59,13 @@ export async function connectTarget(
return admitConnect(targetId)
}
clearSshCredentialDeclined(targetId)
+ // Why explicit only: an automatic connect (startup restore) must not lift the re-pair cap.
+ const managedEnvironmentId = getManagedOrcadFenceEnvironmentId(
+ getSshTargetRegistryStore()?.getTarget(targetId)
+ )
+ if (managedEnvironmentId) {
+ noteRuntimeEnvironmentConnectRequested(managedEnvironmentId)
+ }
const liftedEnvironments = liftSshHostUserDisconnect(targetId)
try {
return await admitConnect(targetId)
diff --git a/src/main/ipc/ssh/ssh-connect-pairing-repair-reset.test.ts b/src/main/ipc/ssh/ssh-connect-pairing-repair-reset.test.ts
new file mode 100644
index 00000000000..c0c48ad0d4a
--- /dev/null
+++ b/src/main/ipc/ssh/ssh-connect-pairing-repair-reset.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = await vi.hoisted(async () => {
+ const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks')
+ return createSshIpcMocks()
+})
+
+vi.mock('../../ssh/config/ssh-config-host-picker', () => mocks.sshConfigHostPicker)
+vi.mock('electron', () => mocks.electron)
+vi.mock('../../ssh/connection/ssh-connection-store', () => mocks.sshConnectionStore)
+vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
+vi.mock('../../ssh/connection/ssh-connection-manager', () => mocks.sshConnectionManager)
+vi.mock('../../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
+vi.mock('../../ssh/port-forwarding/ssh-port-forward', () => mocks.sshPortForward)
+vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner)
+
+import type { SshTarget } from '../../../shared/ssh-types'
+import { setRefusedPairingHandlers } from '../runtime-environments/runtime-environment-pairing-refusal-registry'
+import { createSshIpcHarness } from './ssh-ipc-test-harness'
+
+const managedTarget: SshTarget = {
+ id: 'ssh-1',
+ label: 'Server',
+ host: 'example.com',
+ port: 22,
+ username: 'deploy',
+ orcadFence: { environmentId: 'env-managed' }
+}
+
+describe('ssh:connect and a held managed re-pair failure', () => {
+ const harness = createSshIpcHarness(mocks)
+ const { handlers } = harness
+ const connectRequested = vi.fn()
+
+ beforeEach(async () => {
+ await harness.reset()
+ connectRequested.mockReset()
+ mocks.mockSshStore.getTarget.mockReturnValue(managedTarget)
+ setRefusedPairingHandlers({
+ recover: () => null,
+ sharedControl: () => null,
+ connectRequested
+ })
+ })
+
+ it('an explicit connect clears the hold and cap for the managed server', async () => {
+ await handlers.get('ssh:connect')!(null, { targetId: managedTarget.id })
+
+ expect(connectRequested).toHaveBeenCalledWith('env-managed')
+ })
+
+ it('an automatic connect such as startup restore leaves them in place', async () => {
+ await handlers.get('ssh:connect')!(null, { targetId: managedTarget.id, automatic: true })
+
+ expect(connectRequested).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/managed-server/lifecycle/orcad-managed-bound-port.ts b/src/main/managed-server/lifecycle/orcad-managed-bound-port.ts
index ec23ba61372..b0422978de7 100644
--- a/src/main/managed-server/lifecycle/orcad-managed-bound-port.ts
+++ b/src/main/managed-server/lifecycle/orcad-managed-bound-port.ts
@@ -39,9 +39,9 @@ export function orcadBoundPort(readiness: Pick)
}
}
-async function readSlotBoundPort(
+async function readManagedOrcadSlotReadiness(
target: OrcadRemoteExecTarget & { remoteInstallDir: string }
-): Promise {
+): Promise {
const parsed = parseOrcadReadinessWaitOutput(
target.host,
await execOrcadRemote(
@@ -49,7 +49,14 @@ async function readSlotBoundPort(
readOrcadReadinessNowCommand(target.host, target.remoteInstallDir)
)
)
- return parsed.state === 'ready' ? orcadBoundPort(parsed.readiness) : null
+ return parsed.state === 'ready' ? parsed.readiness : null
+}
+
+async function readSlotBoundPort(
+ target: OrcadRemoteExecTarget & { remoteInstallDir: string }
+): Promise {
+ const readiness = await readManagedOrcadSlotReadiness(target)
+ return readiness ? orcadBoundPort(readiness) : null
}
/** Falls back to the preferred port only when the slot published no endpoint (older builds). */
@@ -60,17 +67,16 @@ export async function readManagedOrcadBoundPort(
return (await readSlotBoundPort(target)) ?? preferredPort
}
-/** The bound port the active slot's readiness names now; null when it names none. */
-async function readActiveSlotBoundPort(input: OrcadTunnelPortInput): Promise {
- if (!input.environment.orcadDeployment) {
- return null
- }
+/** What the active slot publishes now, read over SSH; null when none is active or ready. */
+export async function readActiveManagedOrcadReadiness(
+ input: Pick
+): Promise {
const context = await resolveOrcadRemoteContext(input.target, input.connection)
const active = context.activationRecord.active
if (!active) {
return null
}
- return readSlotBoundPort({
+ return readManagedOrcadSlotReadiness({
conn: context.connection,
host: context.host,
remoteHome: context.remoteHome,
@@ -78,6 +84,15 @@ async function readActiveSlotBoundPort(input: OrcadTunnelPortInput): Promise {
+ if (!input.environment.orcadDeployment) {
+ return null
+ }
+ const readiness = await readActiveManagedOrcadReadiness(input)
+ return readiness ? orcadBoundPort(readiness) : null
+}
+
/** Where a tunnel to this environment must point; independent SSH access keeps its own port. */
export async function resolveManagedOrcadTunnelPort(input: OrcadTunnelPortInput): Promise {
const access = getRuntimeSshAccess(input.environment)
diff --git a/src/main/managed-server/lifecycle/orcad-managed-pairing-repair-failure.ts b/src/main/managed-server/lifecycle/orcad-managed-pairing-repair-failure.ts
new file mode 100644
index 00000000000..c2195cd13e6
--- /dev/null
+++ b/src/main/managed-server/lifecycle/orcad-managed-pairing-repair-failure.ts
@@ -0,0 +1,14 @@
+export const ORCAD_PAIRING_REPAIR_FAILED_CODE = 'orcad_pairing_repair_failed'
+
+/** The host's server refuses this desktop and pairing again over SSH did not fix it. */
+export class OrcadManagedPairingRepairFailedError extends Error {
+ readonly code = ORCAD_PAIRING_REPAIR_FAILED_CODE
+ constructor(reason: string) {
+ super(
+ `${ORCAD_PAIRING_REPAIR_FAILED_CODE}: the Orca server on this host no longer accepts this ` +
+ `computer, and Orca couldn't pair with it again over SSH: ${reason}. Nothing on the host ` +
+ `was stopped; its terminals can't be verified until Orca pairs again. Connect again to retry.`
+ )
+ this.name = 'OrcadManagedPairingRepairFailedError'
+ }
+}
diff --git a/src/main/managed-server/lifecycle/orcad-managed-pairing-repair-restart.test.ts b/src/main/managed-server/lifecycle/orcad-managed-pairing-repair-restart.test.ts
new file mode 100644
index 00000000000..d776a0efc8e
--- /dev/null
+++ b/src/main/managed-server/lifecycle/orcad-managed-pairing-repair-restart.test.ts
@@ -0,0 +1,148 @@
+import { mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { parsePairingCode, type PairingOffer } from '../../../shared/pairing'
+import { pairedRuntimeConnectionFromOffer } from '../../../shared/paired-runtime-connection'
+import {
+ createEnvironmentFromPairingOffer,
+ type KnownRuntimeEnvironment
+} from '../../../shared/runtime-environments'
+import type { SshTarget } from '../../../shared/ssh-types'
+import { DeviceRegistry } from '../../runtime/device-registry'
+import { OrcaRuntimeService } from '../../runtime/orca-runtime'
+import { OrcaRuntimeRpcServer } from '../../runtime/runtime-rpc'
+import { verifyRuntimePairingIdentity } from '../../runtime/runtime-environment-identity-verification'
+import { buildServePairingReadiness } from '../../server/serve-pairing-readiness'
+import type { ServeReadiness } from '../../server/serve-readiness'
+import type { OrcadHostTransport } from '../../ssh/orcad-host-transport'
+import { ManagedOrcadPairingRepairs } from './orcad-managed-pairing-repair'
+import {
+ OrcadManagedIdentityError,
+ verifyManagedOrcadTunnelIdentity
+} from './orcad-managed-tunnel-identity'
+
+vi.mock('../../git/worktree', () => ({
+ listWorktrees: vi.fn().mockResolvedValue([]),
+ listWorktreesStrict: vi.fn().mockResolvedValue([])
+}))
+
+const servers: OrcaRuntimeRpcServer[] = []
+afterEach(async () => {
+ await Promise.all(servers.splice(0).map((server) => server.stop()))
+})
+
+/** An orcad process on `hostProfile`, the host profile that holds its E2EE key and devices. */
+async function startOrcad(hostProfile: string): Promise {
+ const server = new OrcaRuntimeRpcServer({
+ runtime: new OrcaRuntimeService(),
+ userDataPath: hostProfile,
+ enableWebSocket: true,
+ wsPort: 0
+ })
+ await server.start()
+ servers.push(server)
+ return server
+}
+
+/** What the slot publishes in its readiness file, which the repair reads over SSH. */
+async function publishedReadiness(server: OrcaRuntimeRpcServer): Promise {
+ return {
+ runtimeId: 'runtime-now',
+ managedWslCliReconciliation: 'settled',
+ ...(await buildServePairingReadiness({ pairingAddress: '127.0.0.1' }, server))
+ }
+}
+
+function offerIn(readiness: ServeReadiness): PairingOffer {
+ if (!readiness.pairing.available) {
+ throw new Error('pairing unavailable')
+ }
+ return parsePairingCode(readiness.pairing.url)!
+}
+
+/** The desktop's saved server; the tunnel's local port is where the server listens here. */
+function savedEnvironment(offer: PairingOffer, at = 1): KnownRuntimeEnvironment {
+ return {
+ ...createEnvironmentFromPairingOffer({
+ id: 'env-1',
+ name: 'Box server',
+ now: at,
+ offer,
+ connectionDependency: 'ssh-tunnel'
+ }),
+ orcadDeployment: {
+ sshTargetId: 'box',
+ sshTargetGeneration: 1,
+ localPort: Number(new URL(offer.endpoint).port),
+ remotePort: 6_768
+ }
+ }
+}
+
+function repairsAgainst(saved: KnownRuntimeEnvironment, readiness: ServeReadiness) {
+ let current = saved
+ const refresh = vi.fn((_id: string, code: string) => {
+ current = savedEnvironment(parsePairingCode(code)!, 2)
+ return current
+ })
+ const repairs = new ManagedOrcadPairingRepairs({
+ resolve: () => current,
+ connect: async () => ({
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only passed to readReadiness, which ignores it here.
+ target: {} as SshTarget,
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only passed to readReadiness, which ignores it here.
+ connection: {} as OrcadHostTransport
+ }),
+ readReadiness: async () => readiness,
+ refresh,
+ repaired: vi.fn(),
+ now: () => 0
+ })
+ return { repairs, refresh }
+}
+
+describe('a managed orcad that dropped this desktop and restarted', () => {
+ it('reads as our server refusing our token, and pairing again over SSH restores access', async () => {
+ const hostProfile = mkdtempSync(join(tmpdir(), 'orcad-host-'))
+ const first = await startOrcad(hostProfile)
+ const paired = offerIn(await publishedReadiness(first))
+ await verifyRuntimePairingIdentity(pairedRuntimeConnectionFromOffer(paired), {
+ runtimeId: null
+ })
+ await first.stop()
+ // Host side: this desktop's device is removed, then the server restarts.
+ new DeviceRegistry(hostProfile).removeDevice(paired.pairedDeviceId!)
+ const restarted = await startOrcad(hostProfile)
+ const readiness = await publishedReadiness(restarted)
+ const reached = { ...paired, endpoint: offerIn(readiness).endpoint }
+
+ const refused = await verifyManagedOrcadTunnelIdentity(savedEnvironment(reached))
+ // Not "another Orca holds the port": the refusal came sealed under the pinned key.
+ expect(refused.verdict).toBe('unpaired')
+
+ const { repairs, refresh } = repairsAgainst(savedEnvironment(reached), readiness)
+ const repaired = await repairs.repair('env-1', 1)
+ expect(refresh).toHaveBeenCalledOnce()
+ await expect(verifyManagedOrcadTunnelIdentity(repaired)).resolves.toEqual({
+ verdict: 'verified'
+ })
+ })
+
+ it('never pairs with a server whose key differs from the pinned one', async () => {
+ const paired = offerIn(
+ await publishedReadiness(await startOrcad(mkdtempSync(join(tmpdir(), 'orcad-host-'))))
+ )
+ const other = await publishedReadiness(
+ await startOrcad(mkdtempSync(join(tmpdir(), 'orcad-other-')))
+ )
+ const reached = { ...paired, endpoint: offerIn(other).endpoint }
+ await expect(
+ verifyManagedOrcadTunnelIdentity(savedEnvironment(reached))
+ ).resolves.toMatchObject({ verdict: 'foreign' })
+
+ const { repairs, refresh } = repairsAgainst(savedEnvironment(reached), other)
+ await expect(repairs.repair('env-1', 1)).rejects.toBeInstanceOf(OrcadManagedIdentityError)
+ expect(refresh).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/managed-server/lifecycle/orcad-managed-pairing-repair.test.ts b/src/main/managed-server/lifecycle/orcad-managed-pairing-repair.test.ts
new file mode 100644
index 00000000000..d2f85c604c3
--- /dev/null
+++ b/src/main/managed-server/lifecycle/orcad-managed-pairing-repair.test.ts
@@ -0,0 +1,371 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ encodePairingOffer,
+ PAIRING_OFFER_VERSION,
+ type PairingOffer
+} from '../../../shared/pairing'
+import {
+ createEnvironmentFromPairingOffer,
+ type KnownRuntimeEnvironment
+} from '../../../shared/runtime-environments'
+import type { SshTarget } from '../../../shared/ssh-types'
+import type { ServeReadiness } from '../../server/serve-readiness'
+import type { OrcadHostTransport } from '../../ssh/orcad-host-transport'
+import {
+ ManagedOrcadPairingRepairs,
+ MAX_PAIRING_REPAIRS_PER_WINDOW,
+ pairingRevisionOf,
+ PAIRING_REPAIR_FAILURE_HOLD_MS,
+ PAIRING_REPAIR_WINDOW_MS
+} from './orcad-managed-pairing-repair'
+import { OrcadManagedPairingRepairFailedError } from './orcad-managed-pairing-repair-failure'
+import {
+ OrcadManagedIdentityError,
+ OrcadManagedPairingRejectedError
+} from './orcad-managed-tunnel-identity'
+import { buildWithPairingRepair } from './orcad-managed-tunnel-pairing-recovery'
+
+const KEY = 'pinned-host-key'
+
+function offer(deviceToken: string, publicKeyB64 = KEY): PairingOffer {
+ return {
+ v: PAIRING_OFFER_VERSION,
+ endpoint: 'ws://127.0.0.1:46768',
+ deviceToken,
+ publicKeyB64,
+ pairedDeviceId: `device-${deviceToken}`
+ }
+}
+
+function environment(deviceToken: string, revision = 1): KnownRuntimeEnvironment {
+ return {
+ ...createEnvironmentFromPairingOffer({
+ id: 'env-1',
+ name: 'Box server',
+ now: revision,
+ offer: offer(deviceToken),
+ connectionDependency: 'ssh-tunnel'
+ }),
+ orcadDeployment: {
+ sshTargetId: 'box',
+ sshTargetGeneration: 1,
+ localPort: 46_768,
+ remotePort: 6_768
+ }
+ }
+}
+
+function readiness(published: PairingOffer | null): ServeReadiness {
+ return {
+ runtimeId: 'runtime-now',
+ boundEndpoint: 'ws://127.0.0.1:6768',
+ advertisedEndpoint: null,
+ managedWslCliReconciliation: 'settled',
+ pairing: published
+ ? {
+ available: true,
+ url: encodePairingOffer({ ...published, endpoint: 'ws://127.0.0.1:6768' }),
+ endpoint: 'ws://127.0.0.1:6768',
+ deviceId: published.pairedDeviceId!,
+ webClientUrl: null,
+ scope: 'runtime',
+ qr: null
+ }
+ : { available: false, reason: 'disabled_by_operator', guidance: 'Restart with pairing.' }
+ }
+}
+
+const REFUSED = 1
+
+function setup(published: () => ServeReadiness | null) {
+ let current = environment('refused-token', REFUSED)
+ let now = 0
+ const deps = {
+ resolve: vi.fn(() => current),
+ connect: vi.fn(async () => ({
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only handed to readReadiness, a double here.
+ target: {} as SshTarget,
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only handed to readReadiness, a double here.
+ connection: {} as OrcadHostTransport
+ })),
+ readReadiness: vi.fn(async () => published()),
+ refresh: vi.fn((_id: string, _code: string) => {
+ const count = deps.refresh.mock.calls.length
+ current = environment(`fresh-${count}`, REFUSED + count)
+ return current
+ }),
+ repaired: vi.fn(),
+ now: () => now
+ }
+ return {
+ deps,
+ repairs: new ManagedOrcadPairingRepairs(deps),
+ /** The next refusal, seen under whatever pairing is stored now. */
+ refuseCurrent: () => pairingRevisionOf(current),
+ advance: (ms: number) => {
+ now += ms
+ }
+ }
+}
+
+describe('ManagedOrcadPairingRepairs', () => {
+ it('keeps the offer the server publishes now and retires the refused transports once', async () => {
+ const state = setup(() => readiness(offer('fresh-token')))
+ const repaired = await state.repairs.repair('env-1', REFUSED)
+ expect(state.deps.refresh).toHaveBeenCalledOnce()
+ expect(state.deps.repaired).toHaveBeenCalledExactlyOnceWith('env-1')
+ expect(pairingRevisionOf(repaired)).not.toBe(REFUSED)
+ })
+
+ it('runs one repair at a time for concurrent refusals', async () => {
+ const state = setup(() => readiness(offer('fresh-token')))
+ await Promise.all([
+ state.repairs.repair('env-1', REFUSED),
+ state.repairs.repair('env-1', REFUSED)
+ ])
+ expect(state.deps.readReadiness).toHaveBeenCalledOnce()
+ expect(state.deps.repaired).toHaveBeenCalledOnce()
+ })
+
+ it('answers a late refusal of the old pairing with the stored one, without the host', async () => {
+ const state = setup(() => readiness(offer('fresh-token')))
+ const first = await state.repairs.repair('env-1', REFUSED)
+ // A build that started on the old pairing is refused after the repair landed.
+ await expect(state.repairs.repair('env-1', REFUSED)).resolves.toEqual(first)
+ expect(state.deps.readReadiness).toHaveBeenCalledOnce()
+ expect(state.deps.repaired).toHaveBeenCalledOnce()
+ })
+
+ it('refuses an offer under another host key, so a foreign server stays foreign', async () => {
+ const state = setup(() => readiness(offer('fresh-token', 'other-key')))
+ await expect(state.repairs.repair('env-1', REFUSED)).rejects.toBeInstanceOf(
+ OrcadManagedIdentityError
+ )
+ expect(state.deps.refresh).not.toHaveBeenCalled()
+ expect(state.deps.repaired).not.toHaveBeenCalled()
+ })
+
+ it('says why when the server still offers the token it refused', async () => {
+ const state = setup(() => readiness(offer('refused-token')))
+ await expect(state.repairs.repair('env-1', REFUSED)).rejects.toThrow(
+ /still offers the pairing it refused/
+ )
+ expect(state.deps.refresh).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['publishes no readiness', () => null, /hasn't published a pairing/],
+ ['offers no pairing', () => readiness(null), /isn't offering a pairing/]
+ ])('fails clearly when the server %s', async (_name, published, message) => {
+ const state = setup(published)
+ const failure = state.repairs.repair('env-1', REFUSED)
+ await expect(failure).rejects.toBeInstanceOf(OrcadManagedPairingRepairFailedError)
+ await expect(failure).rejects.toThrow(message)
+ })
+
+ it('reports a pairing the store would not keep as a failed repair', async () => {
+ const state = setup(() => readiness(offer('fresh-token')))
+ state.deps.refresh.mockImplementationOnce(() => {
+ throw new Error('The managed pairing does not point at its SSH tunnel.')
+ })
+ await expect(state.repairs.repair('env-1', REFUSED)).rejects.toBeInstanceOf(
+ OrcadManagedPairingRepairFailedError
+ )
+ })
+
+ it('holds a failure briefly instead of re-reading the host for every reconnect', async () => {
+ const state = setup(() => readiness(offer('refused-token')))
+ await expect(state.repairs.repair('env-1', REFUSED)).rejects.toThrow()
+ await expect(state.repairs.repair('env-1', REFUSED)).rejects.toThrow()
+ expect(state.deps.readReadiness).toHaveBeenCalledOnce()
+ state.advance(PAIRING_REPAIR_FAILURE_HOLD_MS)
+ await expect(state.repairs.repair('env-1', REFUSED)).rejects.toThrow()
+ expect(state.deps.readReadiness).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not count or hold a lost SSH connection, which says nothing about the pairing', async () => {
+ const state = setup(() => readiness(offer('fresh-token')))
+ for (let i = 0; i <= MAX_PAIRING_REPAIRS_PER_WINDOW; i++) {
+ state.deps.connect.mockRejectedValueOnce(new Error('ssh: connection reset'))
+ await expect(state.repairs.repair('env-1', REFUSED)).rejects.toThrow('connection reset')
+ }
+ await expect(state.repairs.repair('env-1', REFUSED)).resolves.toBeDefined()
+ })
+
+ it('does not count an SSH read that dropped mid-way', async () => {
+ const state = setup(() => readiness(offer('fresh-token')))
+ for (let i = 0; i <= MAX_PAIRING_REPAIRS_PER_WINDOW; i++) {
+ state.deps.readReadiness.mockRejectedValueOnce(new Error('ssh: channel closed'))
+ await expect(state.repairs.repair('env-1', REFUSED)).rejects.toThrow('channel closed')
+ }
+ await expect(state.repairs.repair('env-1', REFUSED)).resolves.toBeDefined()
+ })
+
+ it.each(['stale-token', 'refused-token'])(
+ 'keeps a pairing an update stored while the host was being read (answer: %s)',
+ async (answered) => {
+ const state = setup(() => readiness(offer('fresh-token')))
+ const updated = environment('updated-token', 7)
+ state.deps.readReadiness.mockImplementationOnce(async () => {
+ state.deps.resolve.mockReturnValue(updated)
+ return readiness(offer(answered))
+ })
+ await expect(state.repairs.repair('env-1', REFUSED)).resolves.toBe(updated)
+ expect(state.deps.refresh).not.toHaveBeenCalled()
+ expect(state.deps.repaired).not.toHaveBeenCalled()
+ }
+ )
+
+ it('caps attempts per window, failed ones included', async () => {
+ let issued = 0
+ const state = setup(() => readiness(offer(issued++ % 2 ? `issued-${issued}` : 'refused-token')))
+ for (let i = 0; i < MAX_PAIRING_REPAIRS_PER_WINDOW; i++) {
+ await state.repairs.repair('env-1', state.refuseCurrent()).catch(() => undefined)
+ state.advance(PAIRING_REPAIR_FAILURE_HOLD_MS)
+ }
+ const capped = state.repairs.repair('env-1', state.refuseCurrent())
+ await expect(capped).rejects.toBeInstanceOf(OrcadManagedPairingRepairFailedError)
+ await expect(capped).rejects.toThrow(/tried 3 times/)
+ state.advance(PAIRING_REPAIR_WINDOW_MS)
+ state.deps.readReadiness.mockResolvedValueOnce(readiness(offer('late-token')))
+ await expect(state.repairs.repair('env-1', state.refuseCurrent())).resolves.toBeDefined()
+ })
+
+ it('lets an explicit connect retry past both the held failure and the cap', async () => {
+ const state = setup(() => readiness(offer('refused-token')))
+ for (let i = 0; i < MAX_PAIRING_REPAIRS_PER_WINDOW; i++) {
+ await state.repairs.repair('env-1', REFUSED).catch(() => undefined)
+ state.advance(PAIRING_REPAIR_FAILURE_HOLD_MS)
+ }
+ await expect(state.repairs.repair('env-1', REFUSED)).rejects.toThrow(/tried 3 times/)
+ state.repairs.forgetFailures('env-1')
+ state.deps.readReadiness.mockResolvedValueOnce(readiness(offer('fixed-token')))
+ await expect(state.repairs.repair('env-1', REFUSED)).resolves.toBeDefined()
+ })
+
+ it('keeps a connect that lands mid-repair from being undone by that repair', async () => {
+ const state = setup(() => readiness(offer('refused-token')))
+ for (let i = 0; i < MAX_PAIRING_REPAIRS_PER_WINDOW - 1; i++) {
+ await state.repairs.repair('env-1', REFUSED).catch(() => undefined)
+ state.advance(PAIRING_REPAIR_FAILURE_HOLD_MS)
+ }
+ let answer: (value: ServeReadiness) => void = () => {}
+ state.deps.readReadiness.mockImplementationOnce(
+ () => new Promise((resolve) => (answer = resolve))
+ )
+ const inFlight = state.repairs.repair('env-1', REFUSED)
+ await vi.waitFor(() => expect(state.deps.readReadiness).toHaveBeenCalledTimes(3))
+ state.repairs.forgetFailures('env-1')
+ answer(readiness(offer('refused-token')))
+ await expect(inFlight).rejects.toThrow(/still offers/)
+ // Neither the in-flight attempt nor its failure survives the reset: the user gets a full cap.
+ expect(state.repairs.standingFailure('env-1', REFUSED)).toBeNull()
+ for (let i = 0; i < MAX_PAIRING_REPAIRS_PER_WINDOW; i++) {
+ await expect(state.repairs.repair('env-1', REFUSED)).rejects.toThrow(/still offers/)
+ state.advance(PAIRING_REPAIR_FAILURE_HOLD_MS)
+ }
+ expect(state.deps.readReadiness).toHaveBeenCalledTimes(3 + MAX_PAIRING_REPAIRS_PER_WINDOW)
+ })
+})
+
+describe('buildWithPairingRepair', () => {
+ function recovery() {
+ const state = setup(() => readiness(offer('fresh-token')))
+ return {
+ state,
+ recovery: {
+ resolve: state.deps.resolve,
+ repairs: state.repairs,
+ ensure: vi.fn<(environment: KnownRuntimeEnvironment) => Promise>()
+ }
+ }
+ }
+ const refusal = () => new OrcadManagedPairingRejectedError('rejected the pairing token')
+
+ it('re-pairs once on a refusal from our own server, then forwards on the new pairing', async () => {
+ const { state, recovery: deps } = recovery()
+ const build = vi.fn().mockRejectedValueOnce(refusal())
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ await expect(buildWithPairingRepair(deps, 'env-1', build)).resolves.toBe('repaired')
+ warn.mockRestore()
+ expect(state.deps.refresh).toHaveBeenCalledOnce()
+ expect(deps.ensure.mock.calls[0]?.[0].endpoints[0]).toMatchObject({ deviceToken: 'fresh-1' })
+ })
+
+ it('never re-pairs on a foreign answer or lost contact', async () => {
+ const { state, recovery: deps } = recovery()
+ const foreign = new OrcadManagedIdentityError(6_768, '4001: Unauthorized')
+ await expect(
+ buildWithPairingRepair(deps, 'env-1', vi.fn().mockRejectedValue(foreign))
+ ).rejects.toBe(foreign)
+ expect(state.deps.readReadiness).not.toHaveBeenCalled()
+ })
+
+ it('builds at most the capped number of times however many refused opens ask', async () => {
+ const state = setup(() => readiness(offer('refused-token')))
+ const deps = { resolve: state.deps.resolve, repairs: state.repairs, ensure: vi.fn() }
+ const build = vi.fn().mockRejectedValue(refusal())
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ // Every reconnect of a refused socket asks again, far more often than the hold lasts.
+ for (let open = 0; open < 200; open++) {
+ await expect(buildWithPairingRepair(deps, 'env-1', build)).rejects.toBeInstanceOf(
+ OrcadManagedPairingRepairFailedError
+ )
+ state.advance(1_000)
+ }
+ warn.mockRestore()
+ expect(build).toHaveBeenCalledTimes(MAX_PAIRING_REPAIRS_PER_WINDOW)
+ expect(state.deps.readReadiness).toHaveBeenCalledTimes(MAX_PAIRING_REPAIRS_PER_WINDOW)
+ await expect(buildWithPairingRepair(deps, 'env-1', build)).rejects.toThrow(/tried 3 times/)
+ // An explicit connect is the user asking again: the next open builds.
+ state.repairs.forgetFailures('env-1')
+ await expect(buildWithPairingRepair(deps, 'env-1', build)).rejects.toThrow()
+ expect(build).toHaveBeenCalledTimes(MAX_PAIRING_REPAIRS_PER_WINDOW + 1)
+ })
+
+ it('does not block a newer pairing stored while the repaired one was being refused', async () => {
+ const { state, recovery: deps } = recovery()
+ const newer = environment('updated-token', 9)
+ deps.ensure.mockImplementationOnce(async () => {
+ // An update stores a fresh pairing while the repaired one is still being refused.
+ state.deps.resolve.mockReturnValue(newer)
+ throw refusal()
+ })
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ await expect(
+ buildWithPairingRepair(deps, 'env-1', vi.fn().mockRejectedValue(refusal()))
+ ).rejects.toThrow(/refused the new pairing too/)
+ warn.mockRestore()
+ expect(state.repairs.standingFailure('env-1', pairingRevisionOf(newer))).toBeNull()
+ const build = vi.fn().mockResolvedValue(undefined)
+ await expect(buildWithPairingRepair(deps, 'env-1', build)).resolves.toBe('built')
+ expect(build).toHaveBeenCalledWith(newer)
+ })
+
+ it('keeps a connect pressed during the post-repair handshake from being undone', async () => {
+ const { state, recovery: deps } = recovery()
+ deps.ensure.mockImplementationOnce(async () => {
+ // The user presses Connect while the handshake on the new pairing is still running.
+ state.repairs.forgetFailures('env-1')
+ throw refusal()
+ })
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ await expect(
+ buildWithPairingRepair(deps, 'env-1', vi.fn().mockRejectedValue(refusal()))
+ ).rejects.toThrow(/refused the new pairing too/)
+ warn.mockRestore()
+ expect(state.repairs.standingFailure('env-1', state.refuseCurrent())).toBeNull()
+ })
+
+ it('fails clearly, without looping, when the new pairing is refused too', async () => {
+ const { state, recovery: deps } = recovery()
+ deps.ensure.mockRejectedValue(refusal())
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const failure = buildWithPairingRepair(deps, 'env-1', vi.fn().mockRejectedValue(refusal()))
+ await expect(failure).rejects.toBeInstanceOf(OrcadManagedPairingRepairFailedError)
+ await expect(failure).rejects.toThrow(/refused the new pairing too/)
+ warn.mockRestore()
+ expect(deps.ensure).toHaveBeenCalledOnce()
+ expect(state.deps.refresh).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/main/managed-server/lifecycle/orcad-managed-pairing-repair.ts b/src/main/managed-server/lifecycle/orcad-managed-pairing-repair.ts
new file mode 100644
index 00000000000..314c513b7f7
--- /dev/null
+++ b/src/main/managed-server/lifecycle/orcad-managed-pairing-repair.ts
@@ -0,0 +1,283 @@
+/**
+ * Pairing again with a managed orcad that refused this desktop's token. SSH access is what paired
+ * it in the first place, so the repair reads the offer the server publishes now over SSH, the same
+ * way a deploy or update does, and keeps it only when it names the host key this desktop pinned.
+ */
+import { parsePairingCode } from '../../../shared/pairing'
+import { errorMessage } from '../../../shared/error-message'
+import { getManagedOrcadFenceEnvironmentId } from '../../../shared/managed-orcad-ssh-owner'
+import { refreshManagedOrcadPairing } from '../../../shared/runtime-environment-managed-orcad-store'
+import { assertRuntimeEnvironmentNotReconciling } from '../../../shared/runtime-environment-reconciliation-record'
+import { resolveEnvironment } from '../../../shared/runtime-environment-store'
+import {
+ getOrcadSshDeployment,
+ getPreferredPairingOffer,
+ type KnownRuntimeEnvironment
+} from '../../../shared/runtime-environments'
+import type { SshTarget } from '../../../shared/ssh-types'
+import type { ServeReadiness } from '../../server/serve-readiness'
+import type { OrcadHostTransport } from '../../ssh/orcad-host-transport'
+import { tunneledOrcadPairingCode } from '../../ssh/orcad-tunneled-pairing'
+import {
+ getSshConnectionManager,
+ getSshTargetRegistryStore
+} from '../../ssh/targets/ssh-target-registry'
+import { readActiveManagedOrcadReadiness } from './orcad-managed-bound-port'
+import { OrcadManagedIdentityError } from './orcad-managed-tunnel-identity'
+import { OrcadManagedPairingRepairFailedError } from './orcad-managed-pairing-repair-failure'
+import { notifyManagedOrcadPairingRepaired } from './orcad-managed-pairing-repaired-listener'
+
+// Why a cap on attempts: a server that refuses every fresh offer would otherwise re-pair forever.
+export const MAX_PAIRING_REPAIRS_PER_WINDOW = 3
+export const PAIRING_REPAIR_WINDOW_MS = 10 * 60_000
+// Why remembered briefly: every reconnecting pane would otherwise re-read the host over SSH.
+export const PAIRING_REPAIR_FAILURE_HOLD_MS = 15_000
+
+export type ManagedOrcadPairingRepairDeps = {
+ resolve: (environmentId: string) => KnownRuntimeEnvironment
+ connect: (environment: KnownRuntimeEnvironment) => Promise<{
+ target: SshTarget
+ connection: OrcadHostTransport
+ }>
+ readReadiness: (input: {
+ target: SshTarget
+ connection: OrcadHostTransport
+ }) => Promise
+ refresh: (environmentId: string, pairingCode: string) => KnownRuntimeEnvironment
+ /** Retires every transport still authenticating with the refused token, once per repair. */
+ repaired: (environmentId: string) => void | Promise
+ now: () => number
+}
+
+export function liveManagedOrcadPairingRepairDeps(
+ userDataPath: string
+): ManagedOrcadPairingRepairDeps {
+ return {
+ resolve: (environmentId) => resolveEnvironment(userDataPath, environmentId),
+ connect: connectManagedOrcadHost,
+ readReadiness: readActiveManagedOrcadReadiness,
+ refresh: (environmentId, pairingCode) =>
+ refreshManagedOrcadPairing(userDataPath, environmentId, pairingCode),
+ repaired: notifyManagedOrcadPairingRepaired,
+ now: Date.now
+ }
+}
+
+async function connectManagedOrcadHost(environment: KnownRuntimeEnvironment) {
+ const deployment = getOrcadSshDeployment(environment)
+ const store = getSshTargetRegistryStore()
+ const connections = getSshConnectionManager()
+ if (!deployment || !store || !connections) {
+ throw new Error('SSH is unavailable on this client')
+ }
+ const target = store.getTarget(deployment.sshTargetId)
+ if (
+ !target ||
+ target.generation !== deployment.sshTargetGeneration ||
+ getManagedOrcadFenceEnvironmentId(target) !== environment.id
+ ) {
+ throw new Error('the SSH registration for this server was removed or re-created')
+ }
+ return { target, connection: await connections.connect(target) }
+}
+
+/** The pairing revision a refusal was seen under, so a repair that already landed is not redone. */
+export function pairingRevisionOf(environment: KnownRuntimeEnvironment): number {
+ return environment.pairingRevision ?? environment.createdAt
+}
+
+/** A failure that stands for one pairing until `until`; building under that pairing is refused again. */
+type StandingFailure = { error: Error; revision: number; until: number }
+
+/**
+ * The gate for re-pairing a managed server: whether a build may run at all, one repair per
+ * environment at a time, a capped number of attempts per window, and a failure that stands for the
+ * refused pairing. Keyed by the refused revision: a refusal seen under an older pairing than the
+ * stored one was already answered, so it gets the stored pairing without reaching the host.
+ */
+export class ManagedOrcadPairingRepairs {
+ private readonly inFlight = new Map>()
+ private readonly attemptedAt = new Map()
+ private readonly standing = new Map()
+ // Why: a repair that was already running when the user asked again must not undo that reset.
+ private readonly resets = new Map()
+
+ constructor(private readonly deps: ManagedOrcadPairingRepairDeps) {}
+
+ /**
+ * The failure that stands for this pairing now, or null. A build under it would cost SSH execs,
+ * a forward and a handshake only to be refused again, so callers check this before building.
+ */
+ standingFailure(environmentId: string, revision: number): Error | null {
+ const failure = this.standing.get(environmentId)
+ return failure && failure.revision === revision && this.deps.now() < failure.until
+ ? failure.error
+ : null
+ }
+
+ repair(environmentId: string, refusedRevision: number): Promise {
+ const pending = this.inFlight.get(environmentId)
+ if (pending) {
+ return pending
+ }
+ const operation = this.run(environmentId, refusedRevision).finally(() => {
+ this.inFlight.delete(environmentId)
+ })
+ this.inFlight.set(environmentId, operation)
+ return operation
+ }
+
+ /**
+ * The new pairing was refused too, so it counts as a failed repair. Stands under that pairing's
+ * revision: a newer one stored meanwhile was never tried, so it must not be blocked.
+ */
+ recordRefusedAfterRepair(environmentId: string, repairedRevision: number, epoch: number): Error {
+ return this.stand(
+ environmentId,
+ repairedRevision,
+ epoch,
+ new OrcadManagedPairingRepairFailedError('the server refused the new pairing too')
+ )
+ }
+
+ /** An explicit connect is the user asking again: neither a standing failure nor the cap answers it. */
+ forgetFailures(environmentId: string): void {
+ this.standing.delete(environmentId)
+ this.attemptedAt.delete(environmentId)
+ this.resets.set(environmentId, this.resetsOf(environmentId) + 1)
+ }
+
+ private async run(
+ environmentId: string,
+ refusedRevision: number
+ ): Promise {
+ const environment = this.deps.resolve(environmentId)
+ if (pairingRevisionOf(environment) !== refusedRevision) {
+ return environment
+ }
+ const epoch = this.resetsOf(environmentId)
+ const fail = (error: Error): Error => this.stand(environmentId, refusedRevision, epoch, error)
+ const standing = this.standingFailure(environmentId, refusedRevision)
+ if (standing) {
+ throw standing
+ }
+ const recent = this.recentAttempts(environmentId)
+ if (recent.length >= MAX_PAIRING_REPAIRS_PER_WINDOW) {
+ throw fail(capSpentError(recent.length))
+ }
+ assertRuntimeEnvironmentNotReconciling(environment)
+ const deployment = getOrcadSshDeployment(environment)
+ if (!deployment) {
+ throw new OrcadManagedPairingRepairFailedError('this server is not managed over SSH')
+ }
+ // Why counted only once read: losing SSH proves nothing about the pairing.
+ const readiness = await this.deps.readReadiness(await this.deps.connect(environment))
+ // Why re-read before judging the answer: an update or recovery may have stored a newer pairing
+ // while SSH answered; everything after this is synchronous, so the check holds through the write.
+ const current = this.deps.resolve(environmentId)
+ if (pairingRevisionOf(current) !== refusedRevision) {
+ return current
+ }
+ if (this.resetsOf(environmentId) === epoch) {
+ this.attemptedAt.set(environmentId, [...this.recentAttempts(environmentId), this.deps.now()])
+ }
+ const pairingCode = publishedPairingCode(readiness, deployment.localPort, fail)
+ const offer = parsePairingCode(pairingCode)
+ const pinned = getPreferredPairingOffer(environment)
+ // Why refuse: a server with another key is not the one this desktop paired, whatever SSH says.
+ if (!offer || offer.publicKeyB64 !== pinned.publicKeyB64) {
+ throw fail(
+ new OrcadManagedIdentityError(
+ deployment.remotePort,
+ "the host's server now publishes a different host key than the one this desktop pinned"
+ )
+ )
+ }
+ if (offer.deviceToken === pinned.deviceToken) {
+ throw fail(
+ new OrcadManagedPairingRepairFailedError(
+ 'the server still offers the pairing it refused; restarting it on the host issues a new one'
+ )
+ )
+ }
+ let repaired: KnownRuntimeEnvironment
+ try {
+ repaired = this.deps.refresh(environmentId, pairingCode)
+ } catch (error) {
+ throw fail(new OrcadManagedPairingRepairFailedError(errorMessage(error)))
+ }
+ this.standing.delete(environmentId)
+ await this.deps.repaired(environmentId)
+ return repaired
+ }
+
+ private recentAttempts(environmentId: string): number[] {
+ const now = this.deps.now()
+ return (this.attemptedAt.get(environmentId) ?? []).filter(
+ (at) => now - at < PAIRING_REPAIR_WINDOW_MS
+ )
+ }
+
+ /** Taken when a build starts; a failure from before the user's latest Connect must not stand. */
+ resetEpoch(environmentId: string): number {
+ return this.resetsOf(environmentId)
+ }
+
+ private resetsOf(environmentId: string): number {
+ return this.resets.get(environmentId) ?? 0
+ }
+
+ /** Stands briefly, or through the window once the cap is spent, since no repair could run before. */
+ private stand(environmentId: string, revision: number, epoch: number, error: Error): Error {
+ if (this.resetsOf(environmentId) !== epoch) {
+ return error
+ }
+ const recent = this.recentAttempts(environmentId)
+ const capSpent = recent.length >= MAX_PAIRING_REPAIRS_PER_WINDOW
+ this.standing.set(environmentId, {
+ // Why the cap's reason for later callers: no repair can run again until the window ends.
+ error:
+ capSpent && error instanceof OrcadManagedPairingRepairFailedError
+ ? capSpentError(recent.length)
+ : error,
+ revision,
+ until: capSpent
+ ? recent[0] + PAIRING_REPAIR_WINDOW_MS
+ : this.deps.now() + PAIRING_REPAIR_FAILURE_HOLD_MS
+ })
+ return error
+ }
+}
+
+function capSpentError(attempts: number): Error {
+ return new OrcadManagedPairingRepairFailedError(
+ `it tried ${attempts} times in ${PAIRING_REPAIR_WINDOW_MS / 60_000} minutes ` +
+ 'without the server accepting a pairing'
+ )
+}
+
+function publishedPairingCode(
+ readiness: ServeReadiness | null,
+ localPort: number,
+ fail: (error: Error) => Error
+): string {
+ if (!readiness) {
+ throw fail(
+ new OrcadManagedPairingRepairFailedError(
+ "the server hasn't published a pairing Orca can read"
+ )
+ )
+ }
+ if (!readiness.pairing.available) {
+ throw fail(
+ new OrcadManagedPairingRepairFailedError(
+ `the server isn't offering a pairing (${readiness.pairing.guidance})`
+ )
+ )
+ }
+ try {
+ return tunneledOrcadPairingCode(readiness, localPort)
+ } catch (error) {
+ throw fail(new OrcadManagedPairingRepairFailedError(errorMessage(error)))
+ }
+}
diff --git a/src/main/managed-server/lifecycle/orcad-managed-pairing-repaired-listener.ts b/src/main/managed-server/lifecycle/orcad-managed-pairing-repaired-listener.ts
new file mode 100644
index 00000000000..2bf7725cd04
--- /dev/null
+++ b/src/main/managed-server/lifecycle/orcad-managed-pairing-repaired-listener.ts
@@ -0,0 +1,12 @@
+/** How a managed re-pair retires transports still on the refused token; the IPC layer registers it. */
+let listener: (environmentId: string) => void | Promise = () => {}
+
+export function setManagedOrcadPairingRepairedListener(
+ next: (environmentId: string) => void | Promise
+): void {
+ listener = next
+}
+
+export function notifyManagedOrcadPairingRepaired(environmentId: string): void | Promise {
+ return listener(environmentId)
+}
diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity-restart.test.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity-restart.test.ts
index 2bd56ad7a4c..eaf0cae5989 100644
--- a/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity-restart.test.ts
+++ b/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity-restart.test.ts
@@ -158,7 +158,7 @@ describe('reopening a managed tunnel after orcad restarted elsewhere', () => {
expect(restarted.caller).toBe(first.caller)
const paired = parsePairingCode(first.connection.pairingOffer)!
server.getDeviceRegistry()!.removeDevice(paired.pairedDeviceId!)
- await expect(connect()).rejects.toThrow('orcad_identity_mismatch')
+ await expect(connect()).rejects.toThrow('orcad_pairing_rejected')
refreshWslHostPairing(desktopProfile, 'wsl:ubuntu', {
pairingCode: encodePairingOffer(pairingOffer(server)),
localPort: restarted.connection.localPort,
diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity.test.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity.test.ts
index f69bb8041c1..ef1759f52aa 100644
--- a/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity.test.ts
+++ b/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity.test.ts
@@ -48,6 +48,15 @@ describe('classifyOrcadTunnelIdentityFailure', () => {
expect(classifyOrcadTunnelIdentityFailure(rejected).verdict).toBe('foreign')
})
+ it('reads a sealed token refusal as our own server that dropped this pairing', () => {
+ const refused = new RemoteRuntimeClientError(
+ 'unauthorized',
+ 'Remote Orca runtime rejected the pairing token.',
+ { pairingStage: 'access-grant' }
+ )
+ expect(classifyOrcadTunnelIdentityFailure(refused).verdict).toBe('unpaired')
+ })
+
it('reads a different runtime id as another server', () => {
const mismatch = new Error('The endpoint does not match this paired runtime identity.')
expect(classifyOrcadTunnelIdentityFailure(mismatch).verdict).toBe('foreign')
diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity.ts
index 1b580f0cbc8..d832dd809f8 100644
--- a/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity.ts
+++ b/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity.ts
@@ -23,10 +23,13 @@ import type { ServeReadiness } from '../../server/serve-readiness'
import { orcadBoundPort } from './orcad-managed-bound-port'
import { tunneledOrcadPairingCode } from '../../ssh/orcad-tunneled-pairing'
-/** `unreachable` means nothing answered; it is never evidence about which server holds the port. */
+/**
+ * `unreachable` means nothing answered; it is never evidence about which server holds the port.
+ * `unpaired` is our server (it completed the handshake with the pinned key) refusing our token.
+ */
export type OrcadTunnelIdentity =
| { verdict: 'verified' }
- | { verdict: 'foreign' | 'unreachable'; detail: string }
+ | { verdict: 'foreign' | 'unreachable' | 'unpaired'; detail: string }
export const ORCAD_IDENTITY_MISMATCH_CODE = 'orcad_identity_mismatch'
@@ -41,11 +44,44 @@ export class OrcadManagedIdentityError extends Error {
}
}
+export const ORCAD_PAIRING_REJECTED_CODE = 'orcad_pairing_rejected'
+
+/** The host's own server no longer accepts this desktop's pairing; another port won't help. */
+export class OrcadManagedPairingRejectedError extends Error {
+ readonly code = ORCAD_PAIRING_REJECTED_CODE
+ constructor(detail: string) {
+ super(
+ `${ORCAD_PAIRING_REJECTED_CODE}: the managed Orca server on this host no longer accepts ` +
+ `this desktop's pairing (its saved devices were reset, or this desktop was removed ` +
+ `there), so reconnecting alone won't restore access: ${detail}`
+ )
+ this.name = 'OrcadManagedPairingRejectedError'
+ }
+}
+
+/** The refusal for an answer that is not this desktop's server, or no longer accepts its pairing. */
+export function orcadIdentityRefusal(
+ remotePort: number,
+ identity: Exclude
+): Error {
+ return identity.verdict === 'unpaired'
+ ? new OrcadManagedPairingRejectedError(identity.detail)
+ : new OrcadManagedIdentityError(remotePort, identity.detail)
+}
+
// Why 4001/4003: a runtime that rejects our keys closes the socket rather than replying.
const FOREIGN_CLOSE_CODES = new Set([4001, 4003])
export function classifyOrcadTunnelIdentityFailure(error: unknown): OrcadTunnelIdentity {
const detail = error instanceof Error ? error.message : String(error)
+ // Why access-grant only: that refusal arrives sealed, so the holder of our pinned key sent it.
+ if (
+ error instanceof RemoteRuntimeClientError &&
+ error.code === 'unauthorized' &&
+ error.pairingStage === 'access-grant'
+ ) {
+ return { verdict: 'unpaired', detail }
+ }
if (error instanceof RemoteRuntimeClientError && FOREIGN_CLOSE_CODES.has(error.closeCode ?? 0)) {
return { verdict: 'foreign', detail }
}
diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel-pairing-recovery.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel-pairing-recovery.ts
new file mode 100644
index 00000000000..fd9eb1e2388
--- /dev/null
+++ b/src/main/managed-server/lifecycle/orcad-managed-tunnel-pairing-recovery.ts
@@ -0,0 +1,52 @@
+/**
+ * The one place a managed tunnel build turns "our own server refused our token" into a re-pair.
+ * The refusal is proven before this runs: it arrived sealed under the pinned host key, so a
+ * foreign server never reaches the repair; it fails the build as a foreign answer instead.
+ */
+import type { KnownRuntimeEnvironment } from '../../../shared/runtime-environments'
+import { pairingRevisionOf, type ManagedOrcadPairingRepairs } from './orcad-managed-pairing-repair'
+import { OrcadManagedPairingRejectedError } from './orcad-managed-tunnel-identity'
+
+export type ManagedOrcadPairingRecovery = {
+ resolve: (environmentId: string) => KnownRuntimeEnvironment
+ repairs: ManagedOrcadPairingRepairs
+ /** Forwards on the new pairing; the refusal already removed the old forward. */
+ ensure: (environment: KnownRuntimeEnvironment) => Promise
+}
+
+/**
+ * Builds once; a refused pairing re-pairs over SSH and forwards once more on the new one. The
+ * repair gate decides first whether a build may run: while a failure stands for this pairing,
+ * every caller (each refused socket reopen included) gets it without reaching the host.
+ */
+export async function buildWithPairingRepair(
+ recovery: ManagedOrcadPairingRecovery,
+ environmentId: string,
+ build: (environment: KnownRuntimeEnvironment) => Promise
+): Promise<'built' | 'repaired'> {
+ const refused = recovery.resolve(environmentId)
+ const epoch = recovery.repairs.resetEpoch(environmentId)
+ const standing = recovery.repairs.standingFailure(environmentId, pairingRevisionOf(refused))
+ if (standing) {
+ throw standing
+ }
+ try {
+ await build(refused)
+ return 'built'
+ } catch (error) {
+ if (!(error instanceof OrcadManagedPairingRejectedError)) {
+ throw error
+ }
+ console.warn('[ssh] The managed Orca server refused this desktop; pairing again:', error)
+ }
+ // Why the refused revision: a repair another caller already landed is joined, not redone.
+ const repaired = await recovery.repairs.repair(environmentId, pairingRevisionOf(refused))
+ try {
+ await recovery.ensure(repaired)
+ } catch (error) {
+ throw error instanceof OrcadManagedPairingRejectedError
+ ? recovery.repairs.recordRefusedAfterRepair(environmentId, pairingRevisionOf(repaired), epoch)
+ : error
+ }
+ return 'repaired'
+}
diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel-target.test.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel-target.test.ts
index 6713e63d9c8..13dbb28c649 100644
--- a/src/main/managed-server/lifecycle/orcad-managed-tunnel-target.test.ts
+++ b/src/main/managed-server/lifecycle/orcad-managed-tunnel-target.test.ts
@@ -6,6 +6,7 @@ import type { SshConnection } from '../../ssh/connection/ssh-connection'
import type { SshPortForwardManager } from '../../ssh/port-forwarding/ssh-port-forward'
import {
OrcadManagedIdentityError,
+ OrcadManagedPairingRejectedError,
type OrcadTunnelIdentity
} from './orcad-managed-tunnel-identity'
import {
@@ -87,6 +88,21 @@ describe('forwardToVerifiedOrcad', () => {
expect(state.removeForwardAndWait).toHaveBeenCalledWith('forward-1')
})
+ it('says the host dropped this pairing, not that another Orca holds the port', async () => {
+ const state = setup(
+ [{ verdict: 'unpaired', detail: 'Remote Orca runtime rejected the pairing token.' }],
+ 58_520
+ )
+ const result = forwardToVerifiedOrcad(state.args)
+ await expect(result).rejects.toBeInstanceOf(OrcadManagedPairingRejectedError)
+ await expect(result).rejects.toThrow(/no longer accepts this desktop's pairing/)
+ await expect(result).rejects.not.toThrow(/another Orca/)
+ expect(state.removeForwardAndWait).toHaveBeenCalledWith('forward-1')
+ // Our own server refusing our token is not a moved port: no re-read, no wake.
+ expect(state.rereadRemotePort).not.toHaveBeenCalled()
+ expect(state.addForward).toHaveBeenCalledTimes(1)
+ })
+
it('fails when the re-read port is foreign too, without re-reading forever', async () => {
const state = setup([FOREIGN, FOREIGN], 58_520)
await expect(forwardToVerifiedOrcad(state.args)).rejects.toThrow(/remote port 58520/)
diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel-target.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel-target.ts
index 1db0dfb7ca0..cee2d37ffd2 100644
--- a/src/main/managed-server/lifecycle/orcad-managed-tunnel-target.ts
+++ b/src/main/managed-server/lifecycle/orcad-managed-tunnel-target.ts
@@ -9,7 +9,7 @@ import {
type OrcadTunnelPortInput
} from './orcad-managed-bound-port'
import {
- OrcadManagedIdentityError,
+ orcadIdentityRefusal,
verifyManagedOrcadTunnelIdentity,
type OrcadTunnelIdentity
} from './orcad-managed-tunnel-identity'
@@ -105,7 +105,7 @@ export async function environmentForwardChecks(
* Null when `stillCurrent` turned false mid-setup; the forward is already removed then.
* An answer from another runtime re-reads the port once, since orcad may have restarted onto a
* new one or been started there by that re-read, and throws if the port did not move or the new
- * one is foreign too.
+ * one is foreign too. Our own server refusing our token throws at once.
*/
export async function forwardToVerifiedOrcad(
args: VerifiedOrcadForwardArgs
@@ -123,7 +123,7 @@ export async function forwardToVerifiedOrcad(
await args.forwards.removeForwardAndWait(forward.id)
return null
}
- if (identity.verdict !== 'foreign') {
+ if (identity.verdict === 'verified' || identity.verdict === 'unreachable') {
if (identity.verdict === 'unreachable') {
// Why not fail: a server that is down or still starting is not a wrong server.
console.warn(
@@ -133,10 +133,14 @@ export async function forwardToVerifiedOrcad(
return forward
}
await args.forwards.removeForwardAndWait(forward.id)
+ // Why no re-read: our own server refusing our token is not a moved port, and waking it is moot.
+ if (identity.verdict === 'unpaired') {
+ throw orcadIdentityRefusal(remotePort, identity)
+ }
const reread =
attempt === 0 && args.rereadRemotePort ? await args.rereadRemotePort() : remotePort
if (reread === remotePort || !args.stillCurrent()) {
- throw new OrcadManagedIdentityError(remotePort, identity.detail)
+ throw orcadIdentityRefusal(remotePort, identity)
}
remotePort = reread
}
diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel.ts
index 2bf9801a567..782cc863fe8 100644
--- a/src/main/managed-server/lifecycle/orcad-managed-tunnel.ts
+++ b/src/main/managed-server/lifecycle/orcad-managed-tunnel.ts
@@ -14,6 +14,15 @@ import {
MANAGED_ORCAD_TUNNEL_TARGETING,
type OrcadTunnelStartChecks
} from './orcad-managed-tunnel-target'
+import {
+ liveManagedOrcadPairingRepairDeps,
+ ManagedOrcadPairingRepairs,
+ pairingRevisionOf
+} from './orcad-managed-pairing-repair'
+import {
+ buildWithPairingRepair,
+ type ManagedOrcadPairingRecovery
+} from './orcad-managed-tunnel-pairing-recovery'
const managedTunnels = new OrcadManagedTunnelManager({
getConnectionManager: getSshConnectionManager,
@@ -27,27 +36,80 @@ const managedTunnels = new OrcadManagedTunnelManager({
})
})
+const pairingRepairs = new Map()
+
+function pairingRecovery(userDataPath: string): ManagedOrcadPairingRecovery {
+ let repairs = pairingRepairs.get(userDataPath)
+ if (!repairs) {
+ repairs = new ManagedOrcadPairingRepairs(liveManagedOrcadPairingRepairDeps(userDataPath))
+ pairingRepairs.set(userDataPath, repairs)
+ }
+ return {
+ resolve: (environmentId) => resolveEnvironment(userDataPath, environmentId),
+ repairs,
+ ensure: (environment) =>
+ managedTunnels.ensure(environment, () =>
+ resolveEnvironmentOrNull(userDataPath, environment.id)
+ )
+ }
+}
+
+/** Returns the environment as stored once the tunnel stands: a re-pair replaces its credentials. */
export async function ensureOrcadManagedTunnel(
userDataPath: string,
selector: string
-): Promise {
- const environment = resolveEnvironment(userDataPath, selector)
- await managedTunnels.ensure(environment, () =>
- resolveEnvironmentOrNull(userDataPath, environment.id)
+): Promise {
+ const { id } = resolveEnvironment(userDataPath, selector)
+ await buildWithPairingRepair(pairingRecovery(userDataPath), id, (environment) =>
+ managedTunnels.ensure(environment, () => resolveEnvironmentOrNull(userDataPath, id))
)
+ return resolveEnvironment(userDataPath, id)
}
/** After a redeploy: the reused forward may target the port the previous server bound. */
export async function rebuildOrcadManagedTunnel(
userDataPath: string,
selector: string
-): Promise {
- const environment = resolveEnvironment(userDataPath, selector)
- await managedTunnels.rebuild(environment, () =>
- resolveEnvironmentOrNull(userDataPath, environment.id)
+): Promise<'built' | 'repaired'> {
+ const { id } = resolveEnvironment(userDataPath, selector)
+ return buildWithPairingRepair(pairingRecovery(userDataPath), id, (environment) =>
+ managedTunnels.rebuild(environment, () => resolveEnvironmentOrNull(userDataPath, id))
)
}
+/**
+ * A socket through a standing tunnel had its token refused. Forwarding anew re-proves which
+ * server answers, so only our own server's refusal re-pairs; a foreign one fails as foreign.
+ */
+export function recoverRefusedManagedOrcadPairing(
+ userDataPath: string,
+ environmentId: string
+): Promise<'built' | 'repaired'> {
+ return rebuildOrcadManagedTunnel(userDataPath, environmentId)
+}
+
+/** Whether a failed re-pair stands for the pairing stored now; one for an older pairing judges nothing current. */
+export function managedOrcadPairingFailureStands(
+ userDataPath: string,
+ environmentId: string
+): boolean {
+ const environment = resolveEnvironmentOrNull(userDataPath, environmentId)
+ return (
+ environment !== null &&
+ (pairingRepairs
+ .get(userDataPath)
+ ?.standingFailure(environmentId, pairingRevisionOf(environment)) ?? null) !== null
+ )
+}
+
+/** An explicit connect is the user asking again, so recent failed re-pairs must not answer it. */
+export function forgetFailedManagedOrcadPairingRepair(
+ userDataPath: string,
+ environmentId: string
+): void {
+ pairingRepairs.get(userDataPath)?.forgetFailures(environmentId)
+}
+
function resolveEnvironmentOrNull(userDataPath: string, id: string) {
try {
return resolveEnvironment(userDataPath, id)
diff --git a/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts b/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts
index d280bc0cfaa..085326d808c 100644
--- a/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts
+++ b/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts
@@ -6,9 +6,14 @@ import { getManagedOrcadFenceEnvironmentId } from '../../../shared/managed-orcad
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../shared/pairing'
import {
addManagedOrcadEnvironment,
+ refreshManagedOrcadPairing,
removeManagedOrcadEnvironment
} from '../../../shared/runtime-environment-managed-orcad-store'
-import { listEnvironments } from '../../../shared/runtime-environment-store'
+import { listEnvironments, resolveEnvironment } from '../../../shared/runtime-environment-store'
+import {
+ getPreferredPairingOffer,
+ type KnownRuntimeEnvironment
+} from '../../../shared/runtime-environments'
import type { SshTarget } from '../../../shared/ssh-types'
import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness'
import { Store } from '../../persistence/loading-store/store'
@@ -65,7 +70,7 @@ beforeEach(() => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnectionStore wraps the real test store it is given.
mocks.state.targetStore = new SshConnectionStore(store as never)
destination = fakeOrcadMigrationDestination()
- mocks.ensureTunnel.mockResolvedValue(undefined)
+ mocks.ensureTunnel.mockImplementation(resolveEnvironment)
// Registers the server the fence named, as the real deploy would after pairing.
mocks.deploy.mockImplementation(async (path: string, args: { name: string }) => {
const owner = getManagedOrcadFenceEnvironmentId(store.getSshTarget(TARGET.id))
@@ -117,6 +122,38 @@ const convert = (
})
describe('converting an SSH host into a managed server', () => {
+ it('commits first time with the pairing the tunnel re-paired to while it opened', async () => {
+ mocks.ensureTunnel.mockImplementation(async (path: string, id: string) => {
+ // The server refused the stored token; the tunnel paired again over SSH.
+ refreshManagedOrcadPairing(
+ path,
+ id,
+ encodePairingOffer({
+ v: PAIRING_OFFER_VERSION,
+ endpoint: 'ws://127.0.0.1:46768/',
+ deviceToken: 'fresh-token',
+ publicKeyB64: 'public-key'
+ })
+ )
+ return resolveEnvironment(path, id)
+ })
+ const destinationFor = vi.fn((_environment: KnownRuntimeEnvironment) => destination)
+ const result = await convertSshTargetToManagedOrcad(userDataPath, {
+ sshTargetId: TARGET.id,
+ name: 'Managed',
+ censusHost: async () => ({ verdict: 'exited', count: 0 }),
+ destinationFor,
+ releaseDirectSession,
+ now: () => new Date('2026-10-02T00:00:00.000Z')
+ })
+ expect(result).toMatchObject({ outcome: 'converted' })
+ expect(destination.commits).toBe(1)
+ const used = destinationFor.mock.calls[0]![0]
+ expect(getPreferredPairingOffer(used).deviceToken).toBe('fresh-token')
+ // The re-pair keeps the migration mark that blocks rollbacks across this commit.
+ expect(used.orcadMigratedAt).toBe('2026-10-02T00:00:00.000Z')
+ })
+
it('fences, deploys, marks the server, commits once, then keeps the source rows', async () => {
const result = await convert()
expect(result).toMatchObject({ outcome: 'converted' })
diff --git a/src/main/managed-server/lifecycle/orcad-runtime-conversion.ts b/src/main/managed-server/lifecycle/orcad-runtime-conversion.ts
index 944bb817d6e..567f0a8cdd5 100644
--- a/src/main/managed-server/lifecycle/orcad-runtime-conversion.ts
+++ b/src/main/managed-server/lifecycle/orcad-runtime-conversion.ts
@@ -90,12 +90,14 @@ export async function convertSshTargetToManagedOrcad(
throw new Error('orcad_migration_destination_environment_mismatch')
}
// Before any commit can land, so a crash after it still blocks rollbacks across it.
- const marked = recordManagedOrcadMigration(
+ recordManagedOrcadMigration(
userDataPath,
environment.id,
(args.now ?? (() => new Date()))().toISOString()
)
- await ensureOrcadManagedTunnel(userDataPath, environment.id)
+ // Why the record ensure returns: a re-pair while the tunnel stands replaces the credentials
+ // every request below must carry; it keeps the migration mark recorded above.
+ const marked = await ensureOrcadManagedTunnel(userDataPath, environment.id)
const { claims, targetStore } = requireManagedOrcadInfrastructure()
const store = targetStore.getOrcadMigrationSource()
const destination = args.destinationFor(marked)
diff --git a/src/main/managed-server/lifecycle/orcad-runtime-deployment.test.ts b/src/main/managed-server/lifecycle/orcad-runtime-deployment.test.ts
index bdaa09398ae..07aa91dce4b 100644
--- a/src/main/managed-server/lifecycle/orcad-runtime-deployment.test.ts
+++ b/src/main/managed-server/lifecycle/orcad-runtime-deployment.test.ts
@@ -7,7 +7,7 @@ import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../shared/pairi
import { getManagedOrcadFenceEnvironmentId } from '../../../shared/managed-orcad-ssh-owner'
import { writeOrcadMigrationSourceCutover } from '../../ssh/orcad-migration-cutover-journal'
import { orcadMigrationCutoverFixture } from '../../ssh/orcad-migration-cutover-fixture'
-import { listEnvironments } from '../../../shared/runtime-environment-store'
+import { listEnvironments, resolveEnvironment } from '../../../shared/runtime-environment-store'
import type { SshTarget } from '../../../shared/ssh-types'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { SshTargetOrcadClaims } from '../../ssh/targets/ssh-target-orcad-claims'
@@ -174,7 +174,7 @@ beforeEach(() => {
mocks.probe.mockResolvedValue(readiness())
mocks.startTunnel.mockResolvedValue(46_768)
mocks.closeTunnel.mockResolvedValue(undefined)
- mocks.ensureTunnel.mockResolvedValue(undefined)
+ mocks.ensureTunnel.mockImplementation(resolveEnvironment)
mocks.localVersion.mockReturnValue(VERSION)
mocks.wake.mockResolvedValue({ outcome: 'serving' })
})
diff --git a/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts b/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts
index a8ed8ff27b0..3c33d25351e 100644
--- a/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts
+++ b/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts
@@ -118,14 +118,14 @@ export async function createManagedOrcadEnvironment(
}
if (registered) {
environmentRegistered = true
- await ensureOrcadManagedTunnel(userDataPath, registered.id)
+ const current = await ensureOrcadManagedTunnel(userDataPath, registered.id)
const activeVersion = context.activationRecord.active
if (!activeVersion) {
throw new Error('The managed Orca server has no active runtime version.')
}
return {
outcome: 'already-current',
- environment: redactRuntimeEnvironment(registered),
+ environment: redactRuntimeEnvironment(current),
activeVersion
}
}
diff --git a/src/main/ssh/orcad-migration-delta-move.test.ts b/src/main/ssh/orcad-migration-delta-move.test.ts
index 85d9b221a28..213844e2870 100644
--- a/src/main/ssh/orcad-migration-delta-move.test.ts
+++ b/src/main/ssh/orcad-migration-delta-move.test.ts
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store'
-import { listEnvironments } from '../../shared/runtime-environment-store'
+import { listEnvironments, resolveEnvironment } from '../../shared/runtime-environment-store'
import type { Repo } from '../../shared/repo-types'
import type { SshTarget } from '../../shared/ssh-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
@@ -93,7 +93,7 @@ beforeEach(() => {
sshStore = new SshConnectionStore(store as never)
mocks.state.targetStore = sshStore
destination = fakeOrcadMigrationDestination()
- mocks.ensureTunnel.mockResolvedValue(undefined)
+ mocks.ensureTunnel.mockImplementation(resolveEnvironment)
mocks.deploy.mockImplementation(async (path: string, args: { name: string }) => {
const id = getManagedOrcadFenceEnvironmentId(store.getSshTarget(TARGET.id))!
if (!listEnvironments(path).some((entry) => entry.id === id)) {
@@ -142,7 +142,11 @@ async function convertedThenChangedOnOlderBuild(): Promise {
expect(store.getSshTarget(TARGET.id)?.orcadFence?.sourceChangedAt).toBeDefined()
}
-function deltaMove(at: () => Date = now, census: 'exited' | 'live' = 'exited') {
+function deltaMove(
+ at: () => Date = now,
+ census: 'exited' | 'live' = 'exited',
+ connectDestination: () => Promise = async () => destination
+) {
const target = store.getSshTarget(TARGET.id)!
return runOrcadDeltaMove({
userDataPath,
@@ -150,9 +154,8 @@ function deltaMove(at: () => Date = now, census: 'exited' | 'live' = 'exited') {
claims: sshStore.getOrcadRuntimeClaims(),
target,
environment: listEnvironments(userDataPath)[0]!,
- destination,
+ connectDestination,
censusHost: async () => ({ verdict: census, count: census === 'live' ? 1 : 0 }),
- ensureTunnel: async () => {},
runTargetLifecycle,
now: at
})
@@ -424,6 +427,33 @@ describe('moving what an older build added to a converted host', () => {
expect(lease.state).not.toBe('terminated')
})
+ it('commits through the destination the tunnel hands back once it opens', async () => {
+ await convertedThenChangedOnOlderBuild()
+ const opened: string[] = []
+ // A re-pair while the tunnel opens: only the destination built after it can commit.
+ const result = await deltaMove(now, 'exited', async () => {
+ opened.push(listOrcadMigrationSourceCutovers(userDataPath).length === 2 ? 'journaled' : '?')
+ return destination
+ })
+ expect(result).toMatchObject({ outcome: 'moved' })
+ expect(opened).toEqual(['journaled'])
+ expect(destination.commits).toBe(2)
+ })
+
+ it('keeps a delta journaled and the host marked when the tunnel cannot open', async () => {
+ await convertedThenChangedOnOlderBuild()
+ await expect(
+ deltaMove(now, 'exited', async () => {
+ throw new Error('ssh: connection reset')
+ })
+ ).rejects.toThrow('connection reset')
+ expect(destination.commits).toBe(1)
+ expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(2)
+ expect(store.getSshTarget(TARGET.id)?.orcadFence?.sourceChangedAt).toBeDefined()
+ await expect(deltaMove()).resolves.toMatchObject({ outcome: 'moved' })
+ expect(destination.commits).toBe(2)
+ })
+
it('resumes a delta whose commit lost contact, keeping the host marked meanwhile', async () => {
await convertedThenChangedOnOlderBuild()
const reconnect = loseContactAtDeltaCommit()
diff --git a/src/main/ssh/orcad-migration-delta-move.ts b/src/main/ssh/orcad-migration-delta-move.ts
index e949fd1fc71..80c2b055fee 100644
--- a/src/main/ssh/orcad-migration-delta-move.ts
+++ b/src/main/ssh/orcad-migration-delta-move.ts
@@ -54,10 +54,13 @@ export type OrcadDeltaMoveArgs = {
claims: SshTargetOrcadClaims
target: SshTarget
environment: KnownRuntimeEnvironment
- destination: OrcadMigrationDestinationCatalog
+ /**
+ * Opens the tunnel and returns the destination built from the pairing it stands on, the only
+ * way to get one: a re-pair while it opens replaces the credentials a commit must carry.
+ */
+ connectDestination: () => Promise
/** The census of the host's relay endpoints that must prove exit. */
censusHost?: CensusHostRelayTerminals | null
- ensureTunnel: () => Promise
/** Serializes the journal write through the commit with every other change to this host. */
runTargetLifecycle: (targetId: string, operation: () => Promise) => Promise
now?: () => Date
@@ -152,18 +155,25 @@ async function commitDelta(
// The fence goes back without its "changed" mark: the source freezes for the move.
store.updateSshTarget(target.id, { orcadFence: { environmentId: plan.environmentId } })
await args.claims.flush()
+ // Before any commit can land, resumed ones included: a rollback must not cross this move.
+ recordManagedOrcadMigration(userDataPath, plan.environmentId, now().toISOString())
+ let destination: OrcadMigrationDestinationCatalog
+ try {
+ destination = await args.connectDestination()
+ } catch (error) {
+ // Nothing reached the server this time; the journal stays for the next move to resume.
+ await restoreChangedMark(args, cutover, changedAt)
+ throw error
+ }
const context: OrcadMigrationCutoverContext = {
userDataPath,
store: plan.source,
freshSource: () => orcadDeltaSourceStore(store, target, plan.moved, cutover.manifest),
claims: args.claims,
- destination: args.destination,
+ destination,
now
}
- // Before any commit can land, resumed ones included: a rollback must not cross this move.
- recordManagedOrcadMigration(userDataPath, plan.environmentId, now().toISOString())
try {
- await args.ensureTunnel()
const committed = await commitOrcadMigrationDestination(context, cutover.migrationId)
if (committed.phase !== 'destination-committed' && committed.phase !== 'source-retired') {
throw new Error(`orcad_migration_commit_not_proven:${committed.phase}`)
@@ -191,12 +201,7 @@ async function releaseUncommittedDelta(
cutover: OrcadMigrationSourceCutover,
changedAt: string
): Promise<'released' | 'committed' | 'kept'> {
- const restoreMark = async (): Promise => {
- args.store.updateSshTarget(cutover.sshTargetId, {
- orcadFence: { environmentId: cutover.destinationEnvironmentId, sourceChangedAt: changedAt }
- })
- await args.claims.flush()
- }
+ const restoreMark = (): Promise => restoreChangedMark(args, cutover, changedAt)
try {
const result = await abortOrcadMigrationCutover(context, cutover.migrationId, async () => {
// Mark first: a crash before the journal goes leaves a marked host that resumes this delta.
@@ -210,6 +215,17 @@ async function releaseUncommittedDelta(
}
}
+async function restoreChangedMark(
+ args: Pick,
+ cutover: OrcadMigrationSourceCutover,
+ changedAt: string
+): Promise {
+ args.store.updateSshTarget(cutover.sshTargetId, {
+ orcadFence: { environmentId: cutover.destinationEnvironmentId, sourceChangedAt: changedAt }
+ })
+ await args.claims.flush()
+}
+
/** "Keep the server's version": the older build's changes stay only in the retained profile rows. */
export async function keepOrcadServerVersion(args: {
userDataPath: string
diff --git a/src/main/ssh/orcad-migration-delta-snapshot.test.ts b/src/main/ssh/orcad-migration-delta-snapshot.test.ts
index 40a53bad058..6e88cb13168 100644
--- a/src/main/ssh/orcad-migration-delta-snapshot.test.ts
+++ b/src/main/ssh/orcad-migration-delta-snapshot.test.ts
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store'
-import { listEnvironments } from '../../shared/runtime-environment-store'
+import { listEnvironments, resolveEnvironment } from '../../shared/runtime-environment-store'
import type { Repo } from '../../shared/repo-types'
import type { SshTarget } from '../../shared/ssh-types'
import { folderWorkspaceKey } from '../../shared/workspace-scope'
@@ -72,7 +72,7 @@ beforeEach(() => {
sshStore = new SshConnectionStore(store as never)
mocks.state.targetStore = sshStore
destination = fakeOrcadMigrationDestination()
- mocks.ensureTunnel.mockResolvedValue(undefined)
+ mocks.ensureTunnel.mockImplementation(resolveEnvironment)
mocks.deploy.mockImplementation(async (path: string, args: { name: string }) => {
const id = getManagedOrcadFenceEnvironmentId(store.getSshTarget(TARGET.id))!
if (!listEnvironments(path).some((entry) => entry.id === id)) {
@@ -158,12 +158,11 @@ function deltaMoveTypingDuringTerminalCheck(whileChecking: () => void) {
claims: sshStore.getOrcadRuntimeClaims(),
target: store.getSshTarget(TARGET.id)!,
environment: listEnvironments(userDataPath)[0]!,
- destination,
+ connectDestination: async () => destination,
censusHost: async () => {
whileChecking()
return { verdict: 'exited', count: 0 }
},
- ensureTunnel: async () => {},
runTargetLifecycle,
now
})
diff --git a/src/main/ssh/orcad-migration-repository-link.test.ts b/src/main/ssh/orcad-migration-repository-link.test.ts
index d8fa96c18c3..6cefcf6883e 100644
--- a/src/main/ssh/orcad-migration-repository-link.test.ts
+++ b/src/main/ssh/orcad-migration-repository-link.test.ts
@@ -9,7 +9,7 @@ import {
addManagedOrcadEnvironment,
removeManagedOrcadEnvironment
} from '../../shared/runtime-environment-managed-orcad-store'
-import { listEnvironments } from '../../shared/runtime-environment-store'
+import { listEnvironments, resolveEnvironment } from '../../shared/runtime-environment-store'
import type { Repo } from '../../shared/repo-types'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity'
@@ -121,7 +121,7 @@ beforeEach(() => {
targetStore = new SshConnectionStore(store as never)
mocks.state.targetStore = targetStore
destination = serverDestination()
- mocks.ensureTunnel.mockResolvedValue(undefined)
+ mocks.ensureTunnel.mockImplementation(resolveEnvironment)
mocks.deploy.mockImplementation(
async (path: string, args: { name: string; sshTargetId: string }) => {
const owner = getManagedOrcadFenceEnvironmentId(store.getSshTarget(args.sshTargetId))
@@ -426,9 +426,8 @@ describe('moving the host again after its server was stopped', () => {
claims: targetStore.getOrcadRuntimeClaims(),
target: store.getSshTarget(TARGET.id)!,
environment: listEnvironments(userDataPath)[0]!,
- destination,
+ connectDestination: async () => destination,
censusHost: async () => ({ verdict: 'exited', count: 0 }),
- ensureTunnel: async () => {},
runTargetLifecycle
})
}
diff --git a/src/main/ssh/orcad-terminal-census-client.test.ts b/src/main/ssh/orcad-terminal-census-client.test.ts
index fac9bca19c4..db0183d99cf 100644
--- a/src/main/ssh/orcad-terminal-census-client.test.ts
+++ b/src/main/ssh/orcad-terminal-census-client.test.ts
@@ -53,7 +53,7 @@ function answerWith(capabilities: string[], response: unknown) {
describe('managed orcad terminal census client', () => {
beforeEach(() => {
vi.resetAllMocks()
- mocks.ensure.mockResolvedValue(undefined)
+ mocks.ensure.mockResolvedValue(environment)
mocks.verify.mockResolvedValue({ state: 'serving' })
})
@@ -110,6 +110,22 @@ describe('managed orcad terminal census client', () => {
await expect(collect(active)).resolves.toEqual(unverifiable)
})
+ it('asks with the pairing the tunnel re-paired to, not the refused one it was given', async () => {
+ const repaired = createEnvironmentFromPairingOffer({
+ id: 'environment-1',
+ name: 'Managed',
+ now: 2,
+ offer: { v: 2, endpoint: 'ws://127.0.0.1:46768', deviceToken: 'fresh', publicKeyB64: 'k' },
+ connectionDependency: 'ssh-tunnel'
+ })
+ mocks.ensure.mockResolvedValue(repaired)
+ answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { ok: true, result: census })
+ await collect(active)
+ expect(mocks.send.mock.calls[0]?.[0]).toMatchObject({
+ credential: { kind: 'token', deviceToken: 'fresh' }
+ })
+ })
+
it('reads an unparseable activation time as unverifiable', async () => {
await expect(collect({ ...active, activatedAt: 'later' })).resolves.toEqual(unverifiable)
expect(mocks.send).not.toHaveBeenCalled()
diff --git a/src/main/ssh/orcad-terminal-census-client.ts b/src/main/ssh/orcad-terminal-census-client.ts
index 3ad877ceb3d..89af2f2634c 100644
--- a/src/main/ssh/orcad-terminal-census-client.ts
+++ b/src/main/ssh/orcad-terminal-census-client.ts
@@ -44,11 +44,12 @@ export async function collectManagedTerminalCensus(
return UNVERIFIABLE
}
try {
- await ensureOrcadManagedTunnel(userDataPath, environment.id)
+ // Why its answer, not `environment`: the tunnel may have re-paired, retiring that token.
+ const current = await ensureOrcadManagedTunnel(userDataPath, environment.id)
// A server that idled out behind a forward still up is started first, or it never answers.
await verifyOrcadManagedServing(userDataPath, environment.id)
return await requestOrcadTerminalCensus(
- getPreferredPairedRuntimeConnection(environment),
+ getPreferredPairedRuntimeConnection(current),
activatedAt,
timeoutMs,
options
diff --git a/src/main/wsl/wsl-orcad-pairing-identity.ts b/src/main/wsl/wsl-orcad-pairing-identity.ts
index b9aa67b8ac6..602eebbee7c 100644
--- a/src/main/wsl/wsl-orcad-pairing-identity.ts
+++ b/src/main/wsl/wsl-orcad-pairing-identity.ts
@@ -2,7 +2,7 @@ import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-co
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
import type { ServeReadiness } from '../server/serve-readiness'
import {
- OrcadManagedIdentityError,
+ orcadIdentityRefusal,
verifyOrcadPairingIdentity,
type OrcadTunnelIdentity
} from '../managed-server/lifecycle/orcad-managed-tunnel-identity'
@@ -18,8 +18,8 @@ export async function resolveWslOrcadPairing(
): Promise {
const pairingOffer = tunneledOrcadPairingCode(launch.readiness, localPort, paired)
const identity = await verifyIdentity(pairingOffer, launch.readiness)
- if (identity.verdict === 'foreign') {
- throw new OrcadManagedIdentityError(launch.port, identity.detail)
+ if (identity.verdict === 'foreign' || identity.verdict === 'unpaired') {
+ throw orcadIdentityRefusal(launch.port, identity)
}
if (identity.verdict === 'unreachable') {
console.warn(
diff --git a/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx b/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx
index 82e4e4d8897..25d198eee4e 100644
--- a/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx
+++ b/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx
@@ -152,6 +152,10 @@ export function RuntimeAccessGrantList({
{translate(
'auto.components.settings.RuntimeAccessGrantList.8b82879581',
'Anyone with an active grant can connect until you revoke it. Revoking shared access disconnects active clients immediately.'
+ )}{' '}
+ {translate(
+ 'auto.components.settings.RuntimeAccessGrantList.sshAccessCaveat',
+ "Revoking here doesn't stop someone with SSH access to this computer from pairing again. To cut them off, remove their SSH access."
)}
) : null}
diff --git a/src/renderer/src/components/settings/runtime-server-row.tsx b/src/renderer/src/components/settings/runtime-server-row.tsx
index e1dfee189ba..b876354eb7e 100644
--- a/src/renderer/src/components/settings/runtime-server-row.tsx
+++ b/src/renderer/src/components/settings/runtime-server-row.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { AlertTriangle, Link2, Loader2, Server, ServerOff, Trash2 } from 'lucide-react'
import {
- isUserManagedRuntimeEnvironment,
+ runtimePairingRepairRoute,
type PublicKnownRuntimeEnvironment
} from '../../../../shared/runtime-environments'
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
@@ -100,15 +100,28 @@ export function RuntimeServerRow({
snapshot?.verification === 'blocked' &&
!snapshot.retired &&
snapshot.blockedReason === 'access-revoked'
- // Why: managed and cloud-workspace servers are paired by the service that owns them.
- const repairable = !environment.orcadDeployment && isUserManagedRuntimeEnvironment(environment)
- const detailsDescription = accessRevoked
- ? translate(
- 'auto.components.settings.RuntimeEnvironmentsPane.accessRevoked',
- '{{value0}} no longer accepts this computer. Re-pair it with a new access link.',
- { value0: environment.name }
- )
- : getHostDetailsDescription(effectiveDetails)
+ const repairRoute = runtimePairingRepairRoute(environment)
+ const repairable = repairRoute === 'access-link'
+ const detailsDescription = !accessRevoked
+ ? getHostDetailsDescription(effectiveDetails)
+ : repairRoute === 'ssh'
+ ? // Why: Orca pairs a managed server over SSH by itself; it reaches here only when that failed.
+ translate(
+ 'auto.components.settings.RuntimeEnvironmentsPane.managedAccessRevoked',
+ "{{value0}} no longer accepts this computer, and Orca couldn't pair with it again over SSH. Connect to try again.",
+ { value0: environment.name }
+ )
+ : repairRoute === 'connect'
+ ? translate(
+ 'auto.components.settings.RuntimeEnvironmentsPane.connectAccessRevoked',
+ '{{value0}} no longer accepts this computer. Connect to try again.',
+ { value0: environment.name }
+ )
+ : translate(
+ 'auto.components.settings.RuntimeEnvironmentsPane.accessRevoked',
+ '{{value0}} no longer accepts this computer. Re-pair it with a new access link.',
+ { value0: environment.name }
+ )
const connectionState =
details?.status === 'loading' && !runtimeStatusEntry?.status
? 'checking'
diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx
index b66461b3697..946e6a8cfa8 100644
--- a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx
+++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx
@@ -27,6 +27,7 @@ import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { sshConnectVerb } from '@/ssh/ssh-connect-verb'
import { parseExecutionHostId } from '../../../../shared/execution-host'
+import { runtimePairingRepairRoute } from '../../../../shared/runtime-environments'
import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat'
import { clearRuntimeCompatibilityCache } from '@/runtime/runtime-rpc-client'
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-result'
@@ -84,16 +85,22 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
return s.sshConnectionStates.get(parsed.targetId)?.status ?? null
})
+ const parsedHost = parseExecutionHostId(row.hostId)
+ const runtimeEnvironmentId = parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null
+ const repairRoute = useAppStore((s) => {
+ const environment = s.runtimeEnvironments.find((entry) => entry.id === runtimeEnvironmentId)
+ return environment ? runtimePairingRepairRoute(environment) : 'access-link'
+ })
+ const repairableWithAccessLink = repairRoute === 'access-link'
const model = buildHostHeaderMenuModel({
kind: row.kind,
health: row.health,
sshConnected: sshStatus === 'connected',
compatibility: row.compatibility,
- blockedReason: row.blockedReason
+ blockedReason: row.blockedReason,
+ repairableWithAccessLink
})
const removalTarget = resolveHostRemoval(row.hostId)
- const parsedHost = parseExecutionHostId(row.hostId)
- const runtimeEnvironmentId = parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null
const handleManage = useCallback(() => {
openManageHost(row)
@@ -140,10 +147,17 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
// Why: drop any cached "compatible" verdict so the re-probe re-evaluates
// version skew instead of trusting the prior pass.
clearRuntimeCompatibilityCache(parsed.environmentId)
+ // Why connect: a refusal Orca can answer over SSH is retried, not just read back.
+ const retryPairing = row.blockedReason === 'access-revoked' && repairRoute === 'ssh'
try {
- const response = await window.api.runtimeEnvironments.getStatus({
+ const response = await (
+ retryPairing
+ ? window.api.runtimeEnvironments.connect
+ : window.api.runtimeEnvironments.getStatus
+ )({
selector: parsed.environmentId,
- timeoutMs: 10_000
+ // Why longer when pairing again: SSH, the readiness read and two handshakes all fit in it.
+ timeoutMs: retryPairing ? 45_000 : 10_000
})
unwrapRuntimeRpcResult(response)
// Why: feed the probe result into the shared store so the host header and
@@ -175,7 +189,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
setBusy(false)
}
}
- }, [mountedRef, row.hostId, row.label])
+ }, [mountedRef, repairRoute, row.blockedReason, row.hostId, row.label])
return (
diff --git a/src/renderer/src/components/sidebar/host-header-menu-items.test.ts b/src/renderer/src/components/sidebar/host-header-menu-items.test.ts
index 67d2267ef9b..3eff09a4086 100644
--- a/src/renderer/src/components/sidebar/host-header-menu-items.test.ts
+++ b/src/renderer/src/components/sidebar/host-header-menu-items.test.ts
@@ -129,4 +129,15 @@ describe('buildHostHeaderMenuModel', () => {
}).actions
).not.toContain('runtime-repair')
})
+
+ it('offers Check connection, not an access link, for a server Orca pairs over SSH', () => {
+ const actions = buildHostHeaderMenuModel({
+ kind: 'runtime',
+ health: 'blocked',
+ blockedReason: 'access-revoked',
+ repairableWithAccessLink: false
+ }).actions
+ expect(actions).not.toContain('runtime-repair')
+ expect(actions).toContain('runtime-check-connection')
+ })
})
diff --git a/src/renderer/src/components/sidebar/host-header-menu-items.ts b/src/renderer/src/components/sidebar/host-header-menu-items.ts
index 309917a4c9d..fc4a42773fb 100644
--- a/src/renderer/src/components/sidebar/host-header-menu-items.ts
+++ b/src/renderer/src/components/sidebar/host-header-menu-items.ts
@@ -34,6 +34,8 @@ export type HostHeaderMenuInput = {
sshConnected?: boolean
compatibility?: RuntimeCompatVerdict
blockedReason?: RuntimeHostBlockedReason
+ /** False for a server Orca pairs itself (over SSH), whose repair is Connect, not an access link. */
+ repairableWithAccessLink?: boolean
}
function sshActions(connected: boolean): HostHeaderMenuAction[] {
@@ -48,7 +50,8 @@ export function buildHostHeaderMenuModel(input: HostHeaderMenuInput): HostHeader
if (
input.kind === 'runtime' &&
input.health === 'blocked' &&
- input.blockedReason === 'access-revoked'
+ input.blockedReason === 'access-revoked' &&
+ input.repairableWithAccessLink !== false
) {
actions.push('runtime-repair')
}
diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx
index c324331c357..10d373480bc 100644
--- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx
+++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx
@@ -19,7 +19,10 @@ import {
} from '../../../../shared/execution-host'
import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry'
import { pickerExecutionHosts } from '../../../../shared/managed-orcad-execution-host'
-import { isListedRemoteServer } from '../../../../shared/runtime-environments'
+import {
+ isListedRemoteServer,
+ runtimePairingRepairRoute
+} from '../../../../shared/runtime-environments'
import { RuntimeHostStatusRow } from './RuntimeHostStatusRow'
import { connectedHostCountLabel, connectingHostsLabel } from './ssh-status-segment-copy'
import { SshTargetStatusRow } from './SshTargetStatusRow'
@@ -96,14 +99,15 @@ export function SshStatusSegment({
// Why the registry's verdict: it is the same blocked reason the sidebar and Settings show.
const blockedReason =
registryHost?.health === 'blocked' ? registryHost.blockedReason : undefined
+ const repairRoute = runtimePairingRepairRoute(environment)
return {
id: environment.id,
label: registryHost?.label || environment.name || environment.id,
blockedReason,
blocked: blockedReason
? {
- ...runtimeHostBlockedCopy(blockedReason, registryHost?.compatibility),
- canRepair: blockedReason === 'access-revoked'
+ ...runtimeHostBlockedCopy(blockedReason, registryHost?.compatibility, repairRoute),
+ canRepair: blockedReason === 'access-revoked' && repairRoute === 'access-link'
}
: null,
snapshot: statusEntry?.snapshot,
diff --git a/src/renderer/src/components/status-bar/remote-host-connection-status.test.ts b/src/renderer/src/components/status-bar/remote-host-connection-status.test.ts
index fefbcc9d21a..2050a7ded8c 100644
--- a/src/renderer/src/components/status-bar/remote-host-connection-status.test.ts
+++ b/src/renderer/src/components/status-bar/remote-host-connection-status.test.ts
@@ -21,6 +21,11 @@ describe('blocked runtime hosts', () => {
it('words each refusal for what the user must do', () => {
expect(runtimeHostBlockedCopy('access-revoked').label).toBe('Access revoked')
+ expect(runtimeHostBlockedCopy('access-revoked').explanation).toMatch(/new pairing link/)
+ expect(runtimeHostBlockedCopy('access-revoked', undefined, 'ssh').explanation).toMatch(/SSH/)
+ // WSL and cloud VMs never re-pair over SSH, so they are not told they tried.
+ const onConnect = runtimeHostBlockedCopy('access-revoked', undefined, 'connect').explanation
+ expect(onConnect).toBe('Connect to try again.')
const update = runtimeHostBlockedCopy('incompatible', {
kind: 'blocked',
reason: 'server-too-old',
diff --git a/src/renderer/src/components/status-bar/remote-host-connection-status.ts b/src/renderer/src/components/status-bar/remote-host-connection-status.ts
index 8bdccdb164a..196e495d690 100644
--- a/src/renderer/src/components/status-bar/remote-host-connection-status.ts
+++ b/src/renderer/src/components/status-bar/remote-host-connection-status.ts
@@ -2,6 +2,7 @@ import { translate } from '@/i18n/i18n'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types'
import type { RuntimeHostBlockedReason } from '../../../../shared/runtime-host-status'
+import type { RuntimePairingRepairRoute } from '../../../../shared/runtime-environments'
import {
describeRuntimeCompatBlock,
type RuntimeCompatVerdict
@@ -81,7 +82,8 @@ export type RuntimeHostBlockedCopy = {
export function runtimeHostBlockedCopy(
reason: RuntimeHostBlockedReason,
- compatibility?: RuntimeCompatVerdict
+ compatibility?: RuntimeCompatVerdict,
+ repairRoute: RuntimePairingRepairRoute = 'access-link'
): RuntimeHostBlockedCopy {
if (reason === 'access-revoked') {
return {
@@ -93,10 +95,22 @@ export function runtimeHostBlockedCopy(
'auto.components.status.bar.RuntimeHostStatusRow.access_revoked_summary',
'This server revoked access for this computer'
),
- explanation: translate(
- 'auto.components.status.bar.RuntimeHostStatusRow.access_revoked_explanation',
- 'Re-pair with a new pairing link from the server to reconnect.'
- )
+ // Why: Orca pairs a managed server over SSH by itself; it reaches here only when that failed.
+ explanation:
+ repairRoute === 'access-link'
+ ? translate(
+ 'auto.components.status.bar.RuntimeHostStatusRow.access_revoked_explanation',
+ 'Re-pair with a new pairing link from the server to reconnect.'
+ )
+ : repairRoute === 'ssh'
+ ? translate(
+ 'auto.components.status.bar.RuntimeHostStatusRow.managed_access_revoked_explanation',
+ "Orca couldn't pair with it again over SSH. Connect to try again."
+ )
+ : translate(
+ 'auto.components.status.bar.RuntimeHostStatusRow.connect_access_revoked_explanation',
+ 'Connect to try again.'
+ )
}
}
return {
diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx
index 7128cdbeb91..fb51aa04631 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx
@@ -7,6 +7,7 @@ import { ClaudeDiffPanelNoticeBanner } from './ClaudeDiffPanelNoticeBanner'
import { NewerDaemonHoldBanner } from './NewerDaemonHoldBanner'
import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay'
import { useWslHostUnavailableReason } from './use-wsl-host-unavailable-reason'
+import { useManagedPairingRefusedEnvironmentId } from './use-managed-pairing-refused'
import { DelayedRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner'
import { TerminalProcessExitOverlay } from './TerminalProcessExitOverlay'
import { MobileDriverOverlay } from './MobileDriverOverlay'
@@ -170,6 +171,7 @@ export function TerminalPaneRecoveryPortals({
const { managedPanes, paneTransportsRef, ptyRecoveryStatesByPaneId, showSshReconnectOverlay } =
controller
const wslHostUnavailable = useWslHostUnavailableReason(controller.worktreeId)
+ const pairingRefusedEnvironmentId = useManagedPairingRefusedEnvironmentId(controller.worktreeId)
if (showSshReconnectOverlay) {
return null
}
@@ -186,8 +188,22 @@ export function TerminalPaneRecoveryPortals({
key={`remote-runtime-reconnect-${pane.id}`}
epoch={recoveryState.epoch}
phase={recoveryState.phase}
- onReconnect={() => paneTransportsRef.current.get(pane.id)?.retryRecovery?.()}
+ onReconnect={() => {
+ const retry = (): void => {
+ paneTransportsRef.current.get(pane.id)?.retryRecovery?.()
+ }
+ // Why connect first: it asks Orca to pair again, which a pane retry alone does not.
+ if (pairingRefusedEnvironmentId) {
+ void window.api.runtimeEnvironments
+ .connect({ selector: pairingRefusedEnvironmentId })
+ .catch(() => undefined)
+ .finally(retry)
+ return
+ }
+ retry()
+ }}
wslHostUnavailable={wslHostUnavailable}
+ pairingRefused={pairingRefusedEnvironmentId !== null}
/>,
pane.container,
`remote-runtime-reconnect-${pane.id}`
diff --git a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx
index 409a0780e12..b4a652b7795 100644
--- a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx
@@ -37,6 +37,24 @@ describe('TerminalRemoteRuntimeReconnectBanner', () => {
await user.click(screen.getByRole('button', { name: 'Reconnect' }))
expect(onReconnect).toHaveBeenCalledOnce()
})
+
+ it('says the host refused this computer instead of spinning, even mid-recovery', async () => {
+ const onReconnect = vi.fn()
+ const user = userEvent.setup()
+ render(
+
+ )
+
+ expect(screen.getByText('This host no longer accepts this computer')).toBeInTheDocument()
+ expect(screen.getByText(/couldn't pair with it again over SSH/)).toBeInTheDocument()
+ expect(screen.queryByText('Reconnecting to remote runtime')).not.toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Reconnect' }))
+ expect(onReconnect).toHaveBeenCalledOnce()
+ })
})
describe('DelayedRemoteRuntimeReconnectBanner', () => {
diff --git a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx
index 5c96f6c7826..fe3760342bd 100644
--- a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx
@@ -37,22 +37,25 @@ export function useRemoteRuntimeRecoveryBannerRevealed(needsAction: boolean): bo
export function TerminalRemoteRuntimeReconnectBanner({
phase,
onReconnect,
- wslHostUnavailable = null
+ wslHostUnavailable = null,
+ pairingRefused = false
}: {
phase: VisibleRecoveryPhase
onReconnect: () => void
/** The pane's WSL host cannot run: nothing is retrying, so say why instead. */
wslHostUnavailable?: WslHostUnavailableReason | null
+ /** The pane's managed server refuses this desktop and pairing again over SSH failed. */
+ pairingRefused?: boolean
}): React.JSX.Element {
if (wslHostUnavailable) {
return
}
- const retrying = phase !== 'disconnected'
+ const retrying = phase !== 'disconnected' && !pairingRefused
return (
- {retrying
+ {pairingRefused
? translate(
- 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingTitle',
- 'Reconnecting to remote runtime'
+ 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.pairingRefusedTitle',
+ 'This host no longer accepts this computer'
)
- : translate(
- 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedTitle',
- 'Remote runtime disconnected'
- )}
+ : retrying
+ ? translate(
+ 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingTitle',
+ 'Reconnecting to remote runtime'
+ )
+ : translate(
+ 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedTitle',
+ 'Remote runtime disconnected'
+ )}
- {retrying
+ {pairingRefused
? translate(
- 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingBody',
- 'Orca is retrying automatically. This terminal will resume if the connection returns.'
+ 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.pairingRefusedBody',
+ "Orca couldn't pair with it again over SSH. Nothing on the host was stopped. Reconnect to try again."
)
- : translate(
- 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedBody',
- 'Automatic retries stopped. Reconnect to resume this terminal session.'
- )}
+ : retrying
+ ? translate(
+ 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingBody',
+ 'Orca is retrying automatically. This terminal will resume if the connection returns.'
+ )
+ : translate(
+ 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedBody',
+ 'Automatic retries stopped. Reconnect to resume this terminal session.'
+ )}
{!retrying ? (
@@ -111,7 +124,9 @@ export function DelayedRemoteRuntimeReconnectBanner({
epoch: number
}): React.JSX.Element | null {
const revealed = useRemoteRuntimeRecoveryBannerRevealed(
- props.phase === 'disconnected' || Boolean(props.wslHostUnavailable)
+ props.phase === 'disconnected' ||
+ Boolean(props.wslHostUnavailable) ||
+ Boolean(props.pairingRefused)
)
return revealed ?
: null
}
diff --git a/src/renderer/src/components/terminal-pane/use-managed-pairing-refused.test.ts b/src/renderer/src/components/terminal-pane/use-managed-pairing-refused.test.ts
new file mode 100644
index 00000000000..fc0938f9b74
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/use-managed-pairing-refused.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest'
+import {
+ createTestStore,
+ runtimeOwnedRepoState,
+ seedStore
+} from '../../store/slices/store-test-helpers'
+import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status'
+import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
+import { selectManagedPairingRefusedEnvironmentId } from './use-managed-pairing-refused'
+
+const WORKTREE = 'box-repo::/home/me/app'
+
+function stateWith(
+ deployment: PublicKnownRuntimeEnvironment['orcadDeployment'],
+ snapshot: Partial
+) {
+ const store = createTestStore()
+ seedStore(store, {
+ ...runtimeOwnedRepoState('box', 'box-repo', '/home/me/app'),
+ runtimeEnvironments: [
+ {
+ id: 'box',
+ name: 'Box server',
+ createdAt: 0,
+ updatedAt: 0,
+ lastUsedAt: null,
+ runtimeId: null,
+ preferredEndpointId: 'ws',
+ endpoints: [],
+ ...(deployment ? { orcadDeployment: deployment } : {})
+ }
+ ]
+ })
+ store.setState({
+ runtimeStatusByEnvironmentId: new Map([
+ [
+ 'box',
+ {
+ status: null,
+ checkedAt: 1,
+ snapshot: {
+ environmentId: 'box',
+ pairingRevision: 0,
+ sequence: 1,
+ checkedAt: 1,
+ status: null,
+ verification: 'blocked',
+ transport: 'disconnected',
+ blockedReason: 'access-revoked',
+ ...snapshot
+ }
+ }
+ ]
+ ])
+ })
+ return store.getState()
+}
+
+const SSH = { sshTargetId: 'box', sshTargetGeneration: 1, localPort: 46_768, remotePort: 6_768 }
+
+describe('selectManagedPairingRefusedEnvironmentId', () => {
+ it('names a server managed over SSH that refused this desktop after a failed re-pair', () => {
+ expect(selectManagedPairingRefusedEnvironmentId(stateWith(SSH, {}), WORKTREE)).toBe('box')
+ })
+
+ const quiet: [string, typeof SSH | undefined, Partial][] = [
+ ['a server the user pairs with a link', undefined, {}],
+ ['a server that is only unavailable', SSH, { verification: 'unavailable' }],
+ ['an incompatible server', SSH, { blockedReason: 'incompatible' }],
+ ['a retired status owner', SSH, { retired: true }]
+ ]
+ it.each(quiet)('stays quiet for %s', (_name, deployment, snapshot) => {
+ expect(
+ selectManagedPairingRefusedEnvironmentId(stateWith(deployment, snapshot), WORKTREE)
+ ).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/use-managed-pairing-refused.ts b/src/renderer/src/components/terminal-pane/use-managed-pairing-refused.ts
new file mode 100644
index 00000000000..b049705d77a
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/use-managed-pairing-refused.ts
@@ -0,0 +1,38 @@
+import { parseExecutionHostId } from '../../../../shared/execution-host'
+import { getOrcadSshDeployment } from '../../../../shared/runtime-environments'
+import { workspaceOwnerHostId } from '@/lib/workspace-row-owner-host'
+import { useAppStore } from '@/store'
+import type { AppState } from '@/store/types'
+
+/**
+ * The server managed over SSH that owns this workspace when it refuses this desktop and pairing
+ * again over SSH failed; null otherwise. Nothing retries then, so the pane says so.
+ */
+export function selectManagedPairingRefusedEnvironmentId(
+ state: AppState,
+ worktreeId: string | null | undefined
+): string | null {
+ if (!worktreeId) {
+ return null
+ }
+ const host = parseExecutionHostId(workspaceOwnerHostId(state, worktreeId))
+ if (host?.kind !== 'runtime') {
+ return null
+ }
+ const environment = state.runtimeEnvironments.find((entry) => entry.id === host.environmentId)
+ if (!environment || !getOrcadSshDeployment(environment)) {
+ return null
+ }
+ const snapshot = state.runtimeStatusByEnvironmentId.get(environment.id)?.snapshot
+ return snapshot?.verification === 'blocked' &&
+ !snapshot.retired &&
+ snapshot.blockedReason === 'access-revoked'
+ ? environment.id
+ : null
+}
+
+export function useManagedPairingRefusedEnvironmentId(
+ worktreeId: string | null | undefined
+): string | null {
+ return useAppStore((state) => selectManagedPairingRefusedEnvironmentId(state, worktreeId))
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index dd74f629593..57c4e6247a1 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -3682,6 +3682,8 @@
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "Reconnecting to remote runtime",
+ "pairingRefusedTitle": "This host no longer accepts this computer",
+ "pairingRefusedBody": "Orca couldn't pair with it again over SSH. Nothing on the host was stopped. Reconnect to try again.",
"disconnectedTitle": "Remote runtime disconnected",
"retryingBody": "Orca is retrying automatically. This terminal will resume if the connection returns.",
"disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.",
@@ -4474,6 +4476,8 @@
"access_revoked": "Access revoked",
"access_revoked_summary": "This server revoked access for this computer",
"access_revoked_explanation": "Re-pair with a new pairing link from the server to reconnect.",
+ "managed_access_revoked_explanation": "Orca couldn't pair with it again over SSH. Connect to try again.",
+ "connect_access_revoked_explanation": "Connect to try again.",
"update_required": "Update required",
"update_required_summary": "Orca versions are not compatible",
"update_required_explanation": "Update Orca on this computer or on the server, then connect again.",
@@ -8626,6 +8630,7 @@
"e57dde9d93": "Unsaved changes"
},
"RuntimeAccessGrantList": {
+ "sshAccessCaveat": "Revoking here doesn't stop someone with SSH access to this computer from pairing again. To cut them off, remove their SSH access.",
"8b82879581": "Anyone with an active grant can connect until you revoke it. Revoking shared access disconnects active clients immediately.",
"68ec21309f": "Revoke access",
"6f6d5188ed": "Revoke {{value0}}",
@@ -8736,6 +8741,8 @@
"cloudVmWorkflow": "Cloud VM",
"cloudVmWorkflowHelp": "Manage recipe-created cloud machines",
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
+ "managedAccessRevoked": "{{value0}} no longer accepts this computer, and Orca couldn't pair with it again over SSH. Connect to try again.",
+ "connectAccessRevoked": "{{value0}} no longer accepts this computer. Connect to try again.",
"repair": "Re-pair…",
"serverAccessRevoked": "Access revoked",
"orcaVersionLastSeen": "Orca v{{value0}} (last seen)"
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 7ddabe03ed9..de7982393a3 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -3410,6 +3410,8 @@
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "Reconnecting to remote runtime",
+ "pairingRefusedTitle": "Este host ya no acepta este equipo",
+ "pairingRefusedBody": "Orca no pudo volver a emparejarse con él por SSH. No se detuvo nada en el host. Vuelve a conectar para intentarlo de nuevo.",
"disconnectedTitle": "Remote runtime disconnected",
"retryingBody": "Orca está reintentando automáticamente. Este terminal se reanudará si vuelve la conexión.",
"disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.",
@@ -4224,6 +4226,8 @@
"access_revoked": "Access revoked",
"access_revoked_summary": "This server revoked access for this computer",
"access_revoked_explanation": "Re-pair with a new pairing link from the server to reconnect.",
+ "managed_access_revoked_explanation": "Orca no pudo volver a emparejarse con él por SSH. Conéctate para intentarlo de nuevo.",
+ "connect_access_revoked_explanation": "Conéctate para intentarlo de nuevo.",
"update_required": "Update required",
"update_required_summary": "Orca versions are not compatible",
"update_required_explanation": "Update Orca on this computer or on the server, then connect again.",
@@ -8298,6 +8302,7 @@
"e57dde9d93": "Cambios no guardados"
},
"RuntimeAccessGrantList": {
+ "sshAccessCaveat": "Revocar el acceso aquí no impide que alguien con acceso SSH a este equipo vuelva a emparejarse. Para bloquearlo, retira su acceso SSH.",
"8b82879581": "Cualquier persona con una concesión activa puede conectarse hasta que la revoques. Revocar el acceso compartido desconecta a los clientes activos inmediatamente.",
"68ec21309f": "Revocar acceso",
"6f6d5188ed": "Revocar {{value0}}",
@@ -8408,6 +8413,8 @@
"serverRuntimeUnavailableDescription": "El transporte SSH está conectado, pero el tiempo de ejecución de Orca no respondió. Es posible que el host todavía esté ejecutándose.",
"serverReconnecting": "Reconectando",
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
+ "managedAccessRevoked": "{{value0}} ya no acepta este equipo y Orca no pudo volver a emparejarse con él por SSH. Conéctate para intentarlo de nuevo.",
+ "connectAccessRevoked": "{{value0}} ya no acepta este equipo. Conéctate para intentarlo de nuevo.",
"repair": "Re-pair…",
"serverAccessRevoked": "Access revoked",
"orcaVersionLastSeen": "Orca v{{value0}} (last seen)"
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index efa8e6f61ec..c26cbc86a06 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -3544,6 +3544,8 @@
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "Reconnexion au runtime distant",
+ "pairingRefusedTitle": "Cet hôte n'accepte plus cet ordinateur",
+ "pairingRefusedBody": "Orca n'a pas pu s'associer de nouveau à lui via SSH. Rien n'a été arrêté sur l'hôte. Reconnectez-vous pour réessayer.",
"disconnectedTitle": "Runtime distant déconnecté",
"retryingBody": "Orca réessaie automatiquement. Ce terminal reprendra si la connexion revient.",
"disconnectedBody": "Les tentatives automatiques sont interrompues. Reconnectez-vous pour reprendre cette session de terminal.",
@@ -4312,6 +4314,8 @@
"access_revoked": "Access revoked",
"access_revoked_summary": "This server revoked access for this computer",
"access_revoked_explanation": "Re-pair with a new pairing link from the server to reconnect.",
+ "managed_access_revoked_explanation": "Orca n'a pas pu s'associer de nouveau à lui via SSH. Connectez-vous pour réessayer.",
+ "connect_access_revoked_explanation": "Connectez-vous pour réessayer.",
"update_required": "Update required",
"update_required_summary": "Orca versions are not compatible",
"update_required_explanation": "Update Orca on this computer or on the server, then connect again.",
@@ -8449,6 +8453,7 @@
"e57dde9d93": "Modifications non enregistrées"
},
"RuntimeAccessGrantList": {
+ "sshAccessCaveat": "Révoquer l'accès ici n'empêche pas une personne ayant un accès SSH à cet ordinateur de s'associer de nouveau. Pour la bloquer, retirez son accès SSH.",
"8b82879581": "Toute personne disposant d'une autorisation active peut se connecter jusqu'à sa révocation. Révoquer l'accès partagé déconnecte immédiatement les clients actifs.",
"68ec21309f": "Révoquer l'accès",
"6f6d5188ed": "Révoquer {{value0}}",
@@ -8559,6 +8564,8 @@
"serverRuntimeUnavailableDescription": "Le transport SSH est connecté, mais le runtime Orca n'a pas répondu. L'hôte est peut-être toujours en cours d'exécution.",
"serverReconnecting": "Reconnexion",
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
+ "managedAccessRevoked": "{{value0}} n'accepte plus cet ordinateur, et Orca n'a pas pu s'associer de nouveau à lui via SSH. Connectez-vous pour réessayer.",
+ "connectAccessRevoked": "{{value0}} n'accepte plus cet ordinateur. Connectez-vous pour réessayer.",
"repair": "Re-pair…",
"serverAccessRevoked": "Access revoked",
"orcaVersionLastSeen": "Orca v{{value0}} (last seen)"
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index e06208563e0..c70b844ef98 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -3415,6 +3415,8 @@
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "リモートランタイムに再接続中",
+ "pairingRefusedTitle": "このホストはこのコンピューターを受け付けなくなりました",
+ "pairingRefusedBody": "Orca は SSH 経由で再ペアリングできませんでした。ホスト上では何も停止していません。再接続してもう一度お試しください。",
"disconnectedTitle": "リモートランタイムが切断されました",
"retryingBody": "Orca は自動的に再試行します。接続が復元されると、このターミナルが再開されます。",
"disconnectedBody": "自動再試行が停止しました。このターミナルセッションを再開するには再接続してください。",
@@ -4204,6 +4206,8 @@
"access_revoked": "Access revoked",
"access_revoked_summary": "This server revoked access for this computer",
"access_revoked_explanation": "Re-pair with a new pairing link from the server to reconnect.",
+ "managed_access_revoked_explanation": "Orca は SSH 経由で再ペアリングできませんでした。接続してもう一度お試しください。",
+ "connect_access_revoked_explanation": "接続してもう一度お試しください。",
"update_required": "Update required",
"update_required_summary": "Orca versions are not compatible",
"update_required_explanation": "Update Orca on this computer or on the server, then connect again.",
@@ -8325,6 +8329,7 @@
"e57dde9d93": "未保存の変更"
},
"RuntimeAccessGrantList": {
+ "sshAccessCaveat": "ここで取り消しても、このコンピューターへの SSH アクセスを持つ人は再びペアリングできます。完全に遮断するには、その SSH アクセスを削除してください。",
"8b82879581": "アクティブな許可を持っている人は誰でも、許可を取り消すまで接続できます。共有アクセスを取り消すと、アクティブなクライアントはただちに切断されます。",
"68ec21309f": "アクセスを取り消す",
"6f6d5188ed": "{{value0}}を取り消す",
@@ -8435,6 +8440,8 @@
"serverRuntimeUnavailableDescription": "SSH トランスポートは接続されていますが、Orca ランタイムが応答しませんでした。ホストがまだ実行中の可能性があります。",
"serverReconnecting": "再接続中",
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
+ "managedAccessRevoked": "{{value0}} はこのコンピューターを受け付けなくなり、Orca は SSH 経由で再ペアリングできませんでした。接続してもう一度お試しください。",
+ "connectAccessRevoked": "{{value0}} はこのコンピューターを受け付けなくなりました。接続してもう一度お試しください。",
"repair": "Re-pair…",
"serverAccessRevoked": "Access revoked",
"orcaVersionLastSeen": "Orca v{{value0}} (last seen)"
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index b7e9948736f..ffefb364e60 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -3415,6 +3415,8 @@
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "원격 런타임에 다시 연결 중",
+ "pairingRefusedTitle": "이 호스트가 더 이상 이 컴퓨터를 허용하지 않습니다",
+ "pairingRefusedBody": "Orca가 SSH로 다시 페어링하지 못했습니다. 호스트에서는 아무것도 중지되지 않았습니다. 다시 연결하여 재시도하세요.",
"disconnectedTitle": "원격 런타임 연결 끊김",
"retryingBody": "Orca가 자동으로 재시도합니다. 연결이 복원되면 이 터미널이 재개됩니다.",
"disconnectedBody": "자동 재시도가 중지되었습니다. 이 터미널 세션을 재개하려면 다시 연결하세요.",
@@ -4204,6 +4206,8 @@
"access_revoked": "Access revoked",
"access_revoked_summary": "This server revoked access for this computer",
"access_revoked_explanation": "Re-pair with a new pairing link from the server to reconnect.",
+ "managed_access_revoked_explanation": "Orca가 SSH로 다시 페어링하지 못했습니다. 연결하여 다시 시도하세요.",
+ "connect_access_revoked_explanation": "연결하여 다시 시도하세요.",
"update_required": "Update required",
"update_required_summary": "Orca versions are not compatible",
"update_required_explanation": "Update Orca on this computer or on the server, then connect again.",
@@ -8288,6 +8292,7 @@
"e57dde9d93": "저장되지 않은 변경사항"
},
"RuntimeAccessGrantList": {
+ "sshAccessCaveat": "여기서 취소해도 이 컴퓨터에 SSH로 접근할 수 있는 사람은 다시 페어링할 수 있습니다. 차단하려면 해당 SSH 접근 권한을 제거하세요.",
"8b82879581": "활성화된 권한을 가진 사람은 누구나 취소할 때까지 연결할 수 있습니다. 공유 액세스를 취소하면 활성 클라이언트의 연결이 즉시 끊어집니다.",
"68ec21309f": "액세스 취소",
"6f6d5188ed": "{{value0}} 취소",
@@ -8398,6 +8403,8 @@
"serverRuntimeUnavailableDescription": "SSH 전송이 연결되었지만 Orca 런타임이 응답하지 않았습니다. 호스트가 아직 실행 중일 수 있습니다.",
"serverReconnecting": "다시 연결하는 중",
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
+ "managedAccessRevoked": "{{value0}}이(가) 더 이상 이 컴퓨터를 허용하지 않으며, Orca가 SSH로 다시 페어링하지 못했습니다. 연결하여 다시 시도하세요.",
+ "connectAccessRevoked": "{{value0}}이(가) 더 이상 이 컴퓨터를 허용하지 않습니다. 연결하여 다시 시도하세요.",
"repair": "Re-pair…",
"serverAccessRevoked": "Access revoked",
"orcaVersionLastSeen": "Orca v{{value0}} (last seen)"
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 0bb3ba2c0c5..d34ac2fba09 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -3415,6 +3415,8 @@
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "正在重新连接到远程运行时",
+ "pairingRefusedTitle": "此主机不再接受这台电脑",
+ "pairingRefusedBody": "Orca 无法通过 SSH 重新与其配对。主机上没有任何内容被停止。请重新连接以重试。",
"disconnectedTitle": "远程运行时已断开连接",
"retryingBody": "Orca 正在自动重试。如果连接恢复,此终端将恢复。",
"disconnectedBody": "自动重试已停止。重新连接以恢复此终端会话。",
@@ -4204,6 +4206,8 @@
"access_revoked": "Access revoked",
"access_revoked_summary": "This server revoked access for this computer",
"access_revoked_explanation": "Re-pair with a new pairing link from the server to reconnect.",
+ "managed_access_revoked_explanation": "Orca 无法通过 SSH 重新与其配对。请连接以重试。",
+ "connect_access_revoked_explanation": "请连接以重试。",
"update_required": "Update required",
"update_required_summary": "Orca versions are not compatible",
"update_required_explanation": "Update Orca on this computer or on the server, then connect again.",
@@ -8288,6 +8292,7 @@
"e57dde9d93": "未保存的更改"
},
"RuntimeAccessGrantList": {
+ "sshAccessCaveat": "在此撤销并不能阻止拥有这台电脑 SSH 访问权限的人重新配对。要彻底阻止,请移除其 SSH 访问权限。",
"8b82879581": "任何拥有有效授权的人都可以连接,直到您撤销授权为止。撤销共享访问会立即断开活动客户端的连接。",
"68ec21309f": "撤销访问权限",
"6f6d5188ed": "撤销 {{value0}}",
@@ -8398,6 +8403,8 @@
"serverRuntimeUnavailableDescription": "SSH 传输已连接,但 Orca 运行时未应答。主机可能仍在运行。",
"serverReconnecting": "重新连接",
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
+ "managedAccessRevoked": "{{value0}} 不再接受这台电脑,且 Orca 无法通过 SSH 重新与其配对。请连接以重试。",
+ "connectAccessRevoked": "{{value0}} 不再接受这台电脑。请连接以重试。",
"repair": "Re-pair…",
"serverAccessRevoked": "Access revoked",
"orcaVersionLastSeen": "Orca v{{value0}} (last seen)"
diff --git a/src/shared/remote-runtime-shared-control-auth-refusal.test.ts b/src/shared/remote-runtime-shared-control-auth-refusal.test.ts
new file mode 100644
index 00000000000..d4dd2e1ac7b
--- /dev/null
+++ b/src/shared/remote-runtime-shared-control-auth-refusal.test.ts
@@ -0,0 +1,70 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { RemoteRuntimeSharedControlConnection } from './remote-runtime-shared-control-connection'
+import { isRecoverableRemoteRuntimeConnectionError } from './remote-runtime-client-error-classification'
+import {
+ closeSharedControlTestServers,
+ createSharedControlTestServer
+} from './remote-runtime-shared-control-test-server'
+
+afterEach(closeSharedControlTestServers)
+
+// Longer than the first reconnect delay, so a scheduled retry would have dialed again.
+const PAST_FIRST_RETRY_MS = 600
+
+function wait(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+describe('a shared control socket whose token the server refuses', () => {
+ it('hands recovery to its owner instead of retrying the same refused token', async () => {
+ const server = await createSharedControlTestServer({ rejectAuth: true })
+ let finishRecovery: (next: 'retry' | 'stop') => void = () => {}
+ const onAuthenticationRejected = vi.fn(
+ () =>
+ new Promise<'retry' | 'stop'>((resolve) => {
+ finishRecovery = resolve
+ })
+ )
+ const connection = new RemoteRuntimeSharedControlConnection(server.pairing, {
+ onAuthenticationRejected
+ })
+
+ const refused = await connection.request('status.get', undefined, 1_000).catch((e) => e)
+ // Waiting callers see a connection that will return, not a refusal, while it re-pairs.
+ expect(isRecoverableRemoteRuntimeConnectionError(refused)).toBe(true)
+ expect(onAuthenticationRejected).toHaveBeenCalledOnce()
+
+ finishRecovery('stop')
+ await wait(PAST_FIRST_RETRY_MS)
+ expect(server.connectionCount()).toBe(1)
+ expect(connection.getDiagnostics().state).not.toBe('reconnecting')
+ connection.close()
+ })
+
+ it('retries with backoff when its owner did not replace it', async () => {
+ const server = await createSharedControlTestServer({ rejectAuth: true })
+ const connection = new RemoteRuntimeSharedControlConnection(server.pairing, {
+ onAuthenticationRejected: () => Promise.resolve('retry')
+ })
+
+ await connection.request('status.get', undefined, 1_000).catch(() => undefined)
+ await vi.waitFor(() => expect(server.connectionCount()).toBeGreaterThan(1), {
+ timeout: 2_000
+ })
+ connection.close()
+ })
+
+ it('keeps the usual reconnect for a server nobody re-pairs', async () => {
+ const server = await createSharedControlTestServer({ rejectAuth: true })
+ const connection = new RemoteRuntimeSharedControlConnection(server.pairing, {
+ onAuthenticationRejected: () => null
+ })
+
+ const refused = await connection.request('status.get', undefined, 1_000).catch((e) => e)
+ expect(refused).toMatchObject({ code: 'unauthorized' })
+ await vi.waitFor(() => expect(server.connectionCount()).toBeGreaterThan(1), {
+ timeout: 2_000
+ })
+ connection.close()
+ })
+})
diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts
index 94a728cd7f2..858ef891098 100644
--- a/src/shared/remote-runtime-shared-control-connection.ts
+++ b/src/shared/remote-runtime-shared-control-connection.ts
@@ -5,6 +5,7 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
import { remoteRuntimeClientCapabilities } from './remote-runtime-client-capabilities'
import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames'
import { openSharedControlSocket } from './remote-runtime-shared-control-open'
+import { closeSharedControlAfterError } from './remote-runtime-shared-control-refusal'
import * as sharedControlReady from './remote-runtime-shared-control-ready'
import * as sharedControlProtocol from './remote-runtime-shared-control-protocol'
import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect'
@@ -267,27 +268,27 @@ export class RemoteRuntimeSharedControlConnection {
})
}
- private handleSocketClosed(error: RemoteRuntimeClientError, socketGeneration: number): void {
- if (
- !this.socketGeneration.acceptClose({
- generation: socketGeneration,
- error,
- everReady: this.everReady,
- subscriptions: this.subscriptions,
- closeSocket: () => this.closeSocket(error)
- })
- ) {
- return
- }
- this.diagnostics.markError(error.message)
- this.reconnect.scheduleAfterSocketClose({
- intentionallyClosed: this.intentionallyClosed,
- manuallyDisconnected: this.options.isManuallyDisconnected?.() ?? false,
- capabilityPaused: this.options.isCapabilityPaused?.() ?? false,
- subscriptionCount: this.subscriptions.size,
- open: () => this.open()
+ private handleSocketClosed(refused: RemoteRuntimeClientError, socketGeneration: number): void {
+ closeSharedControlAfterError({
+ refused,
+ socketGeneration,
+ generation: this.socketGeneration,
+ onAuthenticationRejected: this.options.onAuthenticationRejected,
+ everReady: this.everReady,
+ subscriptions: this.subscriptions,
+ closeSocket: (error) => this.closeSocket(error),
+ markError: (message) => this.diagnostics.markError(message),
+ isClosed: () => this.state === 'closed',
+ retry: () =>
+ this.reconnect.scheduleAfterSocketClose({
+ intentionallyClosed: this.intentionallyClosed,
+ manuallyDisconnected: this.options.isManuallyDisconnected?.() ?? false,
+ capabilityPaused: this.options.isCapabilityPaused?.() ?? false,
+ subscriptionCount: this.subscriptions.size,
+ open: () => this.open()
+ }),
+ publishDiagnostics: () => this.publishDiagnostics()
})
- this.publishDiagnostics()
}
private closeSocket(error?: Error, preserveReadyWaitersAndPendingRequests = false): void {
diff --git a/src/shared/remote-runtime-shared-control-refusal.ts b/src/shared/remote-runtime-shared-control-refusal.ts
new file mode 100644
index 00000000000..141d1196f53
--- /dev/null
+++ b/src/shared/remote-runtime-shared-control-refusal.ts
@@ -0,0 +1,60 @@
+import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
+import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames'
+import type { SharedControlSocketGeneration } from './remote-runtime-shared-control-socket-generation'
+import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types'
+
+/**
+ * Closes a shared control socket after an error and schedules its retry. A refused token is
+ * refused on every retry, so an owner that can pair again takes over instead, and waiting callers
+ * see a connection that will return rather than a refusal while it does.
+ */
+export function closeSharedControlAfterError(args: {
+ refused: RemoteRuntimeClientError
+ socketGeneration: number
+ generation: SharedControlSocketGeneration
+ onAuthenticationRejected: (() => Promise<'retry' | 'stop'> | null) | undefined
+ everReady: boolean
+ subscriptions: Map>
+ closeSocket: (error: RemoteRuntimeClientError) => void
+ markError: (message: string) => void
+ isClosed: () => boolean
+ retry: () => void
+ publishDiagnostics: () => void
+}): void {
+ // Why current only: a racing close of a retired socket must not start a second recovery.
+ const recovery =
+ args.refused.code === 'unauthorized' && args.generation.isCurrent(args.socketGeneration)
+ ? (args.onAuthenticationRejected?.() ?? null)
+ : null
+ const error = recovery
+ ? remoteRuntimeUnavailableError('Remote Orca runtime refused this pairing; pairing again.')
+ : args.refused
+ if (
+ !args.generation.acceptClose({
+ generation: args.socketGeneration,
+ error,
+ everReady: args.everReady,
+ subscriptions: args.subscriptions,
+ closeSocket: () => args.closeSocket(error)
+ })
+ ) {
+ return
+ }
+ args.markError(error.message)
+ if (!recovery) {
+ args.retry()
+ args.publishDiagnostics()
+ return
+ }
+ // Why closed only: an on-demand open may already be under way when the recovery settles.
+ // A recovery that threw leaves no owner, so the socket retries as usual.
+ void recovery
+ .catch(() => 'retry' as const)
+ .then((next) => {
+ if (next === 'retry' && args.isClosed()) {
+ args.retry()
+ args.publishDiagnostics()
+ }
+ })
+ args.publishDiagnostics()
+}
diff --git a/src/shared/remote-runtime-shared-control-test-server.ts b/src/shared/remote-runtime-shared-control-test-server.ts
index 184df71b4a6..7cbd10c00d5 100644
--- a/src/shared/remote-runtime-shared-control-test-server.ts
+++ b/src/shared/remote-runtime-shared-control-test-server.ts
@@ -39,6 +39,8 @@ type ServerOptions = {
disableAutoPong?: boolean
delayedMethods?: string[]
silentMethods?: string[]
+ /** Refuses every auth the way a server that dropped this device does. */
+ rejectAuth?: boolean
}
const servers: WebSocketServer[] = []
@@ -104,6 +106,10 @@ export async function createSharedControlTestServer(
}
if (!authenticated) {
auths.push(JSON.parse(plaintext))
+ if (options.rejectAuth) {
+ sendEncrypted(ws, sharedKey, { type: 'e2ee_error', error: { code: 'unauthorized' } })
+ return
+ }
authenticated = true
sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' })
if (options.sendBinaryAfterAuth) {
diff --git a/src/shared/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime-shared-control-types.ts
index 7ea3b7fca75..c2b89b0c3d5 100644
--- a/src/shared/remote-runtime-shared-control-types.ts
+++ b/src/shared/remote-runtime-shared-control-types.ts
@@ -83,4 +83,9 @@ export type RemoteRuntimeSharedControlConnectionOptions = {
onAnswered?: () => void
reconnectStableResetMs?: number
liveness?: RemoteRuntimeSocketLivenessOptions
+ /**
+ * The server refused this socket's token. Null leaves the usual reconnect; a promise means the
+ * caller owns recovery and says whether this connection should then retry or stay closed.
+ */
+ onAuthenticationRejected?: () => Promise<'retry' | 'stop'> | null
}
diff --git a/src/shared/runtime-environments-listed-remote-server.test.ts b/src/shared/runtime-environments-listed-remote-server.test.ts
index 4482460b30f..ace9905a55c 100644
--- a/src/shared/runtime-environments-listed-remote-server.test.ts
+++ b/src/shared/runtime-environments-listed-remote-server.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { isListedRemoteServer, isWslHostRuntimeEnvironment } from './runtime-environments'
+import {
+ isListedRemoteServer,
+ isWslHostRuntimeEnvironment,
+ runtimePairingRepairRoute
+} from './runtime-environments'
const wslHost = {
name: 'Remote box',
@@ -32,3 +36,21 @@ describe('the generic remote-server list', () => {
).toBe(true)
})
})
+
+describe('who pairs a server that refused this desktop', () => {
+ it('is SSH only for a server Orca manages over SSH, never WSL or a cloud VM', () => {
+ expect(
+ runtimePairingRepairRoute({
+ orcadDeployment: {
+ sshTargetId: 'box',
+ sshTargetGeneration: 1,
+ localPort: 41000,
+ remotePort: 6768
+ }
+ })
+ ).toBe('ssh')
+ expect(runtimePairingRepairRoute(wslHost)).toBe('connect')
+ expect(runtimePairingRepairRoute({ source: 'ephemeral-vm' })).toBe('connect')
+ expect(runtimePairingRepairRoute({})).toBe('access-link')
+ })
+})
diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts
index 1d6945c681e..bee4190cd06 100644
--- a/src/shared/runtime-environments.ts
+++ b/src/shared/runtime-environments.ts
@@ -235,6 +235,25 @@ export function isUserManagedRuntimeEnvironment(
return !isEphemeralVmRuntimeEnvironment(environment)
}
+/** Only the user re-pairs these, with an access link; Orca pairs managed and cloud servers itself. */
+export function isRepairableWithAccessLink(
+ environment: Pick
+): boolean {
+ return !environment.orcadDeployment && isUserManagedRuntimeEnvironment(environment)
+}
+
+/** Who pairs a server that refused this desktop: the user with a link, Orca over SSH, or Orca on Connect. */
+export type RuntimePairingRepairRoute = 'access-link' | 'ssh' | 'connect'
+
+export function runtimePairingRepairRoute(
+ environment: Pick
+): RuntimePairingRepairRoute {
+ if (getOrcadSshDeployment(environment)) {
+ return 'ssh'
+ }
+ return isRepairableWithAccessLink(environment) ? 'access-link' : 'connect'
+}
+
/** A WSL distro added as a host; keyed by its deployment record's kind, never by its name. */
export function isWslHostRuntimeEnvironment<
T extends { orcadDeployment?: OrcadDeploymentLink | null }
diff --git a/src/shared/runtime-host-status-owner.ts b/src/shared/runtime-host-status-owner.ts
index cfb68e24226..e23ecff2674 100644
--- a/src/shared/runtime-host-status-owner.ts
+++ b/src/shared/runtime-host-status-owner.ts
@@ -158,13 +158,13 @@ export class RuntimeHostStatusOwner {
}
}
- authenticationRejected(): void {
+ authenticationRejected(message = 'Pair this client again.'): void {
if (this.disposed) {
return
}
this.retireRequest()
this.clearRetry()
- this.complete(runtimeHostStatusFailure('unauthorized', 'Pair this client again.'))
+ this.complete(runtimeHostStatusFailure('unauthorized', message))
}
dispose(): void {
diff --git a/tests/e2e/ssh-orcad-pairing-repair.spec.ts b/tests/e2e/ssh-orcad-pairing-repair.spec.ts
new file mode 100644
index 00000000000..33580b2553c
--- /dev/null
+++ b/tests/e2e/ssh-orcad-pairing-repair.spec.ts
@@ -0,0 +1,212 @@
+/**
+ * A managed SSH host whose server dropped this desktop's pairing (its device removed host-side,
+ * then the server restarted) is paired again over SSH, and the open pane answers typing again on
+ * its own. A server that now holds another host key is never paired: connecting reports another
+ * server, and the saved pairing is kept.
+ */
+import type { Page } from '@stablyai/playwright-test'
+import { expect, test } from './helpers/orca-app'
+import { readPairedPaneContent } from './helpers/paired-host-terminal'
+import { reconnect } from './helpers/orcad-convert-flow'
+import { startFreezableTcpProxy, type FreezableTcpProxy } from './helpers/freezable-tcp-proxy'
+import {
+ echoMarker,
+ expectTerminalAnswers,
+ launchManagedWorkspace,
+ openTerminalTab,
+ skipUnlessManagedDockerHost,
+ withManagedHost
+} from './helpers/orcad-managed-workspace'
+
+skipUnlessManagedDockerHost()
+test.skip(process.platform === 'win32', 'The Docker SSH host uses POSIX tooling')
+
+const DEVICES = '/root/.orca/orca-devices.json'
+const KEYPAIR = '/root/.orca/orca-e2ee-keypair.json'
+const KILL_ORCAD = 'kill -TERM $(cat /root/.orca-remote/orcad-*/.orcad-pid)'
+
+async function pairing(page: Page, environmentId: string) {
+ const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find(
+ (entry) => entry.id === environmentId
+ )
+ return {
+ revision: environment?.pairingRevision ?? environment?.createdAt ?? null,
+ deviceId: environment?.pairedDeviceId ?? null
+ }
+}
+
+function reconnectBanner(page: Page, tabId: string): Promise {
+ return page.evaluate((id) => {
+ const pane = document.querySelector(`[data-terminal-tab-id="${id}"]`) ?? document
+ return (
+ pane
+ .querySelector('[data-terminal-remote-runtime-reconnect-banner]')
+ ?.getAttribute('data-terminal-remote-runtime-reconnect-banner') ?? null
+ )
+ }, tabId)
+}
+
+test('a host that dropped this desktop is paired again over SSH and its pane answers', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session.
+{}, testInfo) => {
+ test.setTimeout(15 * 60_000)
+ await withManagedHost(testInfo, async (fixture) => {
+ const { host } = fixture
+ const { page, seeded, environmentId } = await launchManagedWorkspace(fixture)
+ const tabId = await openTerminalTab(page, seeded.worktreeId)
+ await echoMarker(page, tabId, 'BEFORE_REVOKE')
+ const before = await pairing(page, environmentId)
+ expect(before.deviceId).toBeTruthy()
+
+ // Host side, as an admin would: remove this desktop's device, then restart the server.
+ host.exec!(
+ `node -e 'const f=process.argv[1];const fs=require("fs");` +
+ `fs.writeFileSync(f,JSON.stringify(JSON.parse(fs.readFileSync(f,"utf8"))` +
+ `.filter((d)=>d.deviceId!==process.argv[2])))' ${DEVICES} ${before.deviceId}`
+ )
+ host.exec!(KILL_ORCAD)
+
+ try {
+ await expectTerminalAnswers(page, tabId, 4 * 60_000)
+ } catch (error) {
+ console.log(
+ '[pairing-repair] evidence',
+ JSON.stringify({
+ banner: await reconnectBanner(page, tabId),
+ pane: (await readPairedPaneContent(page, tabId)).slice(-600),
+ devices: host.exec!(`cat ${DEVICES}`)
+ })
+ )
+ await page.screenshot({ path: testInfo.outputPath('after-revoke.png') })
+ throw error
+ }
+ const after = await pairing(page, environmentId)
+ expect(after.revision).not.toBe(before.revision)
+ expect(after.deviceId).not.toBe(before.deviceId)
+ await expect.poll(() => reconnectBanner(page, tabId)).toBeNull()
+ await page.screenshot({ path: testInfo.outputPath('repaired.png') })
+ })
+})
+
+test('a server that now holds another host key is never paired, and connecting says so', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session.
+{}, testInfo) => {
+ test.setTimeout(15 * 60_000)
+ await withManagedHost(testInfo, async (fixture) => {
+ const { host } = fixture
+ const { page, seeded, environmentId } = await launchManagedWorkspace(fixture)
+ const tabId = await openTerminalTab(page, seeded.worktreeId)
+ await echoMarker(page, tabId, 'BEFORE_RESET')
+ const before = await pairing(page, environmentId)
+
+ // The server's identity is reset: a new key and no devices, as a wiped profile would have.
+ host.exec!(`rm -f ${KEYPAIR} ${DEVICES} && ${KILL_ORCAD}`)
+
+ // The first connect may find the server down and start it; the next one meets the new key.
+ let connected = ''
+ await expect
+ .poll(
+ async () => {
+ // The helper throws when the connect is refused; the refusal is the answer here.
+ connected = await reconnect(page, seeded.targetId).catch((error: unknown) =>
+ String(error)
+ )
+ return connected
+ },
+ { timeout: 3 * 60_000, intervals: [1_000, 5_000] }
+ )
+ .toMatch(/orcad_identity_mismatch/)
+ expect(connected).not.toMatch(/pairing_repair/)
+ expect(await pairing(page, environmentId)).toEqual(before)
+ // Nothing re-pairs in the background either.
+ await page.waitForTimeout(20_000)
+ expect(await pairing(page, environmentId)).toEqual(before)
+ await page.screenshot({ path: testInfo.outputPath('foreign.png') })
+ })
+})
+
+// Relaunches the server exactly as it ran, with the network to the host down, so the desktop's
+// first look at it is the refusal, and the offer it reads over SSH is the one this test leaves.
+const CAPTURE_LAUNCH =
+ 'f=$(ls /root/.orca-remote/orcad-*/.orcad-pid) && pid=$(cat "$f") && echo "$pid" > /tmp/orcad-old-pid && dirname "$f" > /tmp/orcad-dir && ' +
+ "tr '\\0' '\\n' < /proc/$pid/cmdline > /tmp/orcad-cmd && tr '\\0' '\\n' < /proc/$pid/environ > /tmp/orcad-env"
+const RELAUNCH =
+ 'd=$(cat /tmp/orcad-dir) && cd "$d" && : > .orcad-readiness && ' +
+ 'mapfile -t c < /tmp/orcad-cmd && mapfile -t e < /tmp/orcad-env && ' +
+ '{ env -i "${e[@]}" nohup "${c[@]}" > .orcad-readiness 2>/dev/null < /dev/null & echo $! > .orcad-pid; }'
+const WITHDRAW_OFFER =
+ `node -e 'const fs=require("fs");const f=process.argv[1];fs.writeFileSync(f,fs.readFileSync(f,"utf8")` +
+ `.split("\\n").map((l)=>{try{const j=JSON.parse(l);if(j&&j.pairing){j.pairing={available:false,` +
+ `reason:"disabled_by_operator",guidance:"no offer in this test"}}return JSON.stringify(j)}catch{return l}})` +
+ `.join("\\n"))' "$(cat /tmp/orcad-dir)/.orcad-readiness"`
+
+test('a failed re-pair says why, and the pane stops reconnecting with that verdict', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session.
+{}, testInfo) => {
+ test.setTimeout(15 * 60_000)
+ const opened: FreezableTcpProxy[] = []
+ try {
+ await withManagedHost(testInfo, async (fixture) => {
+ const { host } = fixture
+ const proxy = await startFreezableTcpProxy(host.input.host, host.input.port ?? 22)
+ opened.push(proxy)
+ host.input = { ...host.input, port: proxy.port }
+ const { page, seeded, environmentId } = await launchManagedWorkspace(fixture)
+ const tabId = await openTerminalTab(page, seeded.worktreeId)
+ await echoMarker(page, tabId, 'BEFORE_REVOKE')
+ const before = await pairing(page, environmentId)
+
+ host.exec!(CAPTURE_LAUNCH)
+ proxy.cut('reset')
+ host.exec!(
+ `node -e 'const f=process.argv[1];const fs=require("fs");` +
+ `fs.writeFileSync(f,JSON.stringify(JSON.parse(fs.readFileSync(f,"utf8"))` +
+ `.filter((d)=>d.deviceId!==process.argv[2])))' ${DEVICES} ${before.deviceId}`
+ )
+ host.exec!(
+ `${KILL_ORCAD}; while kill -0 $(cat /tmp/orcad-old-pid) 2>/dev/null; do sleep 0.2; done; true`
+ )
+ host.exec!(RELAUNCH)
+ await expect
+ .poll(
+ () =>
+ host.exec!('test -s "$(cat /tmp/orcad-dir)/.orcad-readiness" && echo ready || true'),
+ {
+ timeout: 60_000
+ }
+ )
+ .toContain('ready')
+ host.exec!(WITHDRAW_OFFER)
+ proxy.restore()
+
+ try {
+ // The pane stops "Reconnecting" and says why, rather than a bare "disconnected".
+ await expect
+ .poll(() => reconnectBanner(page, tabId), { timeout: 4 * 60_000 })
+ .toBe('pairing-refused')
+ } catch (error) {
+ console.log(
+ '[pairing-repair] evidence',
+ JSON.stringify({
+ banner: await reconnectBanner(page, tabId),
+ status: await page.evaluate(
+ (id) => window.__store?.getState().runtimeStatusByEnvironmentId.get(id)?.snapshot,
+ environmentId
+ ),
+ ssh: await page.evaluate(
+ (id) => window.__store?.getState().sshConnectionStates.get(id),
+ seeded.targetId
+ ),
+ readiness: host.exec!('cat "$(cat /tmp/orcad-dir)/.orcad-readiness" | head -c 600')
+ })
+ )
+ await page.screenshot({ path: testInfo.outputPath('repair-failed-evidence.png') })
+ throw error
+ }
+ expect(await page.evaluate(() => document.body.innerText)).toMatch(
+ /couldn.t pair with it again over SSH/
+ )
+ expect(await pairing(page, environmentId)).toEqual(before)
+ await page.screenshot({ path: testInfo.outputPath('repair-failed.png') })
+ })
+ } finally {
+ await Promise.all(opened.map((proxy) => proxy.close()))
+ }
+})