diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index 8ac2808ad95..5c486c025d6 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -2,6 +2,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', 'runtimeEnvironments:verifyAndAddFromPairingCode', + 'runtimeEnvironments:repairFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', 'runtimeEnvironments:disconnect', diff --git a/src/main/ipc/runtime-environment-pairing-verification.ts b/src/main/ipc/runtime-environment-pairing-verification.ts index 1563c1eb633..f592d4c7d96 100644 --- a/src/main/ipc/runtime-environment-pairing-verification.ts +++ b/src/main/ipc/runtime-environment-pairing-verification.ts @@ -1,17 +1,24 @@ import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection' import { addEnvironmentFromPairingCode, - RuntimeEnvironmentStoreError + resolveEnvironment, + RuntimeEnvironmentStoreError, + updateEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' -import { parseHostAccessLink } from '../../shared/remote-pairing-address' +import { parseHostAccessLink, type ParsedHostAccessLink } from '../../shared/remote-pairing-address' import { verifyRemotePairingRuntimeStatus, + type RemotePairingFailure, type VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' -import { redactRuntimeEnvironment } from '../../shared/runtime-environments' +import { + getPreferredPairingOffer, + isEphemeralVmRuntimeEnvironment, + redactRuntimeEnvironment +} from '../../shared/runtime-environments' import type { RuntimeStatus } from '../../shared/runtime-types' type VerifyAndAddRuntimeEnvironmentArgs = { @@ -20,23 +27,28 @@ type VerifyAndAddRuntimeEnvironmentArgs = { allowLoopback?: boolean } -export async function verifyAndAddRuntimeEnvironmentFromPairingCode( - userDataPath: string, - args: VerifyAndAddRuntimeEnvironmentArgs -): Promise { - const parsed = parseHostAccessLink(args.pairingCode) +type VerifiedPairingCode = { + ok: true + link: ParsedHostAccessLink + runtimeStatus: RuntimeStatus +} + +/** Proves a pairing link reaches a compatible host that accepts it, without saving anything. */ +async function verifyPairingCode( + pairingCode: string, + allowLoopback: boolean | undefined +): Promise { + const parsed = parseHostAccessLink(pairingCode) if (!parsed.ok) { return { ok: false, kind: 'access-link-invalid', message: parsed.message } } - if (parsed.value.endpointKind === 'loopback' && !args.allowLoopback) { + if (parsed.value.endpointKind === 'loopback' && !allowLoopback) { return { ok: false, kind: 'host-unreachable', message: 'This access link points back to this device.' } } - - let runtimeStatus: RuntimeStatus try { const response = await sendRemoteRuntimeRequest( pairedRuntimeConnectionFromOffer(parsed.value.pairing), @@ -58,12 +70,26 @@ export async function verifyAndAddRuntimeEnvironmentFromPairingCode( if (!statusVerification.ok) { return statusVerification } - runtimeStatus = statusVerification.runtimeStatus + return { + ok: true, + link: parsed.value, + runtimeStatus: statusVerification.runtimeStatus + } } catch (error) { return classifyPairingVerificationError(error, parsed.value.displayEndpoint) } +} - const usesSshTunnel = parsed.value.endpointKind === 'loopback' && args.allowLoopback === true +export async function verifyAndAddRuntimeEnvironmentFromPairingCode( + userDataPath: string, + args: VerifyAndAddRuntimeEnvironmentArgs +): Promise { + const verified = await verifyPairingCode(args.pairingCode, args.allowLoopback) + if (!verified.ok) { + return verified + } + const { link, runtimeStatus } = verified + const usesSshTunnel = link.endpointKind === 'loopback' && args.allowLoopback === true let environment: ReturnType try { environment = addEnvironmentFromPairingCode(userDataPath, { @@ -87,10 +113,64 @@ export async function verifyAndAddRuntimeEnvironmentFromPairingCode( } } -function classifyPairingVerificationError( - error: unknown, - endpoint: string -): VerifyAndAddRuntimeEnvironmentResult { +/** + * Replaces a saved server's revoked pairing with a fresh link to the same server. Nothing is + * written unless the link verifies and carries the saved server's key, so a bad or foreign link + * leaves the working record untouched. + */ +export async function verifyAndRepairRuntimeEnvironmentFromPairingCode( + userDataPath: string, + args: { selector: string; pairingCode: string } +): Promise { + const existing = resolveEnvironment(userDataPath, args.selector) + if (isEphemeralVmRuntimeEnvironment(existing)) { + return { + ok: false, + kind: 'environment-save-failed', + message: 'This server belongs to a cloud workspace, which pairs it again when it resumes.' + } + } + // Why: a tunnel-backed server is legitimately re-paired with a loopback link. + const verified = await verifyPairingCode( + args.pairingCode, + existing.connectionDependency === 'ssh-tunnel' + ) + if (!verified.ok) { + return verified + } + // Why the key, not runtimeId: runtimeId is minted per server process, so a restart changes it. + if (verified.link.pairing.publicKeyB64 !== getPreferredPairingOffer(existing).publicKeyB64) { + return { + ok: false, + kind: 'different-server', + message: 'This pairing link is for a different server.' + } + } + let environment: ReturnType + try { + environment = updateEnvironmentFromPairingCode(userDataPath, existing.id, { + pairingCode: args.pairingCode, + // Why: the key match proves the same server; a restarted one may report a new runtimeId. + runtimeId: verified.runtimeStatus.runtimeId + }) + } catch (error) { + return { + ok: false, + kind: 'environment-save-failed', + message: + error instanceof RuntimeEnvironmentStoreError && error.code === 'invalid_argument' + ? error.message + : 'Orca verified the server but could not save the new pairing. Check local settings storage and try again.' + } + } + return { + ok: true, + environment: redactRuntimeEnvironment(environment), + runtimeStatus: verified.runtimeStatus + } +} + +function classifyPairingVerificationError(error: unknown, endpoint: string): RemotePairingFailure { if (error instanceof RemoteRuntimeClientError) { if (error.code === 'invalid_argument') { return invalidAccessLinkResult() @@ -128,7 +208,7 @@ function classifyPairingVerificationError( return unreachableHostResult(endpoint) } -function invalidAccessLinkResult(): VerifyAndAddRuntimeEnvironmentResult { +function invalidAccessLinkResult(): RemotePairingFailure { return { ok: false, kind: 'access-link-invalid', @@ -136,7 +216,7 @@ function invalidAccessLinkResult(): VerifyAndAddRuntimeEnvironmentResult { } } -function unreachableHostResult(endpoint: string): VerifyAndAddRuntimeEnvironmentResult { +function unreachableHostResult(endpoint: string): RemotePairingFailure { return { ok: false, kind: 'host-unreachable', diff --git a/src/main/ipc/runtime-environment-repair-handler.ts b/src/main/ipc/runtime-environment-repair-handler.ts new file mode 100644 index 00000000000..8200839ea8e --- /dev/null +++ b/src/main/ipc/runtime-environment-repair-handler.ts @@ -0,0 +1,38 @@ +import { ipcMain } from 'electron' +import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' +import { verifyAndRepairRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification' +import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' +import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect' + +export function registerRuntimeEnvironmentRepairHandler({ + getUserDataPath, + invalidateTransport +}: { + getUserDataPath: () => string + invalidateTransport: (environmentId: string) => Promise | void +}): void { + ipcMain.handle( + 'runtimeEnvironments:repairFromPairingCode', + async ( + _event, + args: { selector: string; pairingCode: string } + ): Promise => { + const result = await verifyAndRepairRuntimeEnvironmentFromPairingCode(getUserDataPath(), args) + if (!result.ok) { + return result + } + const environmentId = result.environment.id + // Why before invalidating: a manually disconnected server would not get a fresh owner. + clearRuntimeEnvironmentManualDisconnect(environmentId) + // Retires the owner blocked under the revoked pairing; its replacement keys on the new revision. + await invalidateTransport(environmentId) + getRuntimeEnvironmentStatusOwner(getUserDataPath(), environmentId).acceptVerified({ + id: 'status.get', + ok: true, + result: result.runtimeStatus, + _meta: { runtimeId: result.runtimeStatus.runtimeId } + }) + return result + } + ) +} diff --git a/src/main/ipc/runtime-environments-ipc-test-harness.ts b/src/main/ipc/runtime-environments-ipc-test-harness.ts index 5fa945a160f..3a810a71b40 100644 --- a/src/main/ipc/runtime-environments-ipc-test-harness.ts +++ b/src/main/ipc/runtime-environments-ipc-test-harness.ts @@ -58,12 +58,15 @@ export function withRuntimeStatusOwners>(transpor } } -export function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { +export function pairingCode( + endpoint = 'ws://127.0.0.1:6768', + { deviceToken = 'device-token', keyByte = 1 }: { deviceToken?: string; keyByte?: number } = {} +): string { return encodePairingOffer({ v: 2, endpoint, - deviceToken: 'device-token', - publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + deviceToken, + publicKeyB64: Buffer.from(new Uint8Array(32).fill(keyByte)).toString('base64') }) } diff --git a/src/main/ipc/runtime-environments-repair.test.ts b/src/main/ipc/runtime-environments-repair.test.ts new file mode 100644 index 00000000000..b4767565401 --- /dev/null +++ b/src/main/ipc/runtime-environments-repair.test.ts @@ -0,0 +1,190 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as environmentStore from '../../shared/runtime-environment-store' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' + +const { handleMock, getPathMock, sendRemoteRuntimeRequestMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + getPathMock: vi.fn(), + sendRemoteRuntimeRequestMock: vi.fn() +})) + +vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, + app: { getPath: getPathMock }, + ipcMain: { + handle: handleMock, + on: vi.fn(), + removeHandler: vi.fn(), + removeAllListeners: vi.fn() + } +})) + +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock, + subscribeRemoteRuntimeRequest: vi.fn() +})) + +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: vi.fn(), + sendRemoteRuntimeSharedControlRequest: vi.fn(() => new Promise(() => {})), + subscribeRemoteRuntimeSharedControlRequest: vi.fn(), + getRemoteRuntimeSharedControlDiagnostics: vi.fn(() => null), + reconnectRemoteRuntimeSharedControlConnection: vi.fn(), + retryRemoteRuntimeSharedControlConnectionsNow: vi.fn(), + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: vi.fn() + }) +}) + +import { registerRuntimeEnvironmentHandlers } from './runtime-environments' +import { + getRuntimeEnvironmentStatusOwner, + getRuntimeEnvironmentStatusSnapshots, + resetRuntimeEnvironmentStatusOwners +} from './runtime-environment-request-connections' +import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' + +const handler = channelHandlerLookup(handleMock) +const ENDPOINT = 'ws://192.168.1.20:6768' + +function statusResponse() { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-after-restart', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + protocolVersion: 999_999 + }, + _meta: { runtimeId: 'runtime-after-restart' } + } +} + +describe('runtimeEnvironments:repairFromPairingCode', () => { + let userDataPath: string + let repair: (event: unknown, args: { selector: string; pairingCode: string }) => unknown + + function savedToken(): string | undefined { + return environmentStore.resolveEnvironment(userDataPath, 'desk').endpoints[0]?.deviceToken + } + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-repair-')) + getPathMock.mockReturnValue(userDataPath) + handleMock.mockReset() + sendRemoteRuntimeRequestMock.mockReset() + const store = { + getSettings: () => ({ activeRuntimeEnvironmentId: null }), + updateSettings: vi.fn(), + removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registrar only reads the fixture's settings and workspace-session methods. + registerRuntimeEnvironmentHandlers(store as never) + environmentStore.addEnvironmentFromPairingCode(userDataPath, { + name: 'desk', + pairingCode: pairingCode(ENDPOINT, { deviceToken: 'revoked-token' }) + }) + repair = handler('runtimeEnvironments:repairFromPairingCode') + }) + + afterEach(() => { + resetRuntimeEnvironmentStatusOwners() + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('replaces a revoked pairing in place and clears the blocked status', async () => { + const before = environmentStore.resolveEnvironment(userDataPath, 'desk') + getRuntimeEnvironmentStatusOwner(userDataPath, before.id).authenticationRejected() + expect(getRuntimeEnvironmentStatusSnapshots()).toMatchObject([ + { verification: 'blocked', blockedReason: 'access-revoked' } + ]) + sendRemoteRuntimeRequestMock.mockResolvedValue(statusResponse()) + + const result = await repair(null, { + selector: 'desk', + pairingCode: pairingCode(ENDPOINT, { deviceToken: 'fresh-token' }) + }) + + expect(result).toMatchObject({ + ok: true, + environment: { id: before.id, name: 'desk' }, + runtimeStatus: { runtimeId: 'runtime-after-restart' } + }) + expect(JSON.stringify(result)).not.toContain('fresh-token') + const after = environmentStore.resolveEnvironment(userDataPath, 'desk') + expect(savedToken()).toBe('fresh-token') + expect(after.pairingRevision).toBeGreaterThan(before.pairingRevision ?? before.createdAt) + const snapshot = getRuntimeEnvironmentStatusSnapshots().find( + (entry) => entry.environmentId === before.id + ) + expect(snapshot).toMatchObject({ + verification: 'verified', + pairingRevision: after.pairingRevision + }) + expect(snapshot).not.toHaveProperty('blockedReason') + }) + + it('refuses a verified link for a different server and keeps the saved pairing', async () => { + sendRemoteRuntimeRequestMock.mockResolvedValue(statusResponse()) + + await expect( + repair(null, { + selector: 'desk', + pairingCode: pairingCode(ENDPOINT, { + deviceToken: 'other-token', + keyByte: 2 + }) + }) + ).resolves.toEqual({ + ok: false, + kind: 'different-server', + message: 'This pairing link is for a different server.' + }) + expect(savedToken()).toBe('revoked-token') + }) + + it('leaves the record untouched for an invalid or rejected link', async () => { + const before = environmentStore.resolveEnvironment(userDataPath, 'desk') + await expect( + repair(null, { selector: 'desk', pairingCode: 'not a link' }) + ).resolves.toMatchObject({ ok: false, kind: 'access-link-invalid' }) + expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled() + + sendRemoteRuntimeRequestMock.mockRejectedValue( + new RemoteRuntimeClientError('unauthorized', 'rejected', { + pairingStage: 'access-grant' + }) + ) + await expect( + repair(null, { + selector: 'desk', + pairingCode: pairingCode(ENDPOINT, { deviceToken: 'also-revoked' }) + }) + ).resolves.toMatchObject({ ok: false, kind: 'access-link-invalid' }) + expect(environmentStore.resolveEnvironment(userDataPath, 'desk')).toEqual(before) + }) + + it('declines a server another Orca service pairs', async () => { + environmentStore.addEnvironmentFromPairingCode(userDataPath, { + name: 'vm', + pairingCode: pairingCode(ENDPOINT, { deviceToken: 'vm-token' }), + source: 'ephemeral-vm' + }) + await expect( + repair(null, { selector: 'vm', pairingCode: pairingCode(ENDPOINT) }) + ).resolves.toMatchObject({ ok: false, kind: 'environment-save-failed' }) + expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 6d0ed7821fb..dc3b19efb19 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -17,6 +17,7 @@ import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler' +import { registerRuntimeEnvironmentRepairHandler } from './runtime-environment-repair-handler' import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' import { resetSharedControlSupport } from './runtime-environment-transport-routing' import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels' @@ -137,6 +138,10 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { getSettings: () => store.getSettings() }) registerRuntimeEnvironmentRecoveryHandler() + registerRuntimeEnvironmentRepairHandler({ + getUserDataPath, + invalidateTransport: invalidateRuntimeEnvironmentTransport + }) registerRuntimeEnvironmentPassiveHandlers(getUserDataPath) for (const environment of listEnvironments(getUserDataPath())) { if ( diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index b50fd541c8e..2427a0e48ac 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -93,6 +93,11 @@ export type RuntimeApi = { pairingCode: string allowLoopback?: boolean }) => Promise + /** Replaces a revoked pairing in place; refuses a link for a different server. */ + repairFromPairingCode: (args: { + selector: string + pairingCode: string + }) => Promise resolve: (args: { selector: string }) => Promise remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> disconnect: (args: { diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index c3bfab9c985..66904e39971 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -45,6 +45,11 @@ export const runtimeEnvironmentsApi = { allowLoopback?: boolean }): Promise => ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), + repairFromPairingCode: (args: { + selector: string + pairingCode: string + }): Promise => + ipcRenderer.invoke('runtimeEnvironments:repairFromPairingCode', args), resolve: (args: { selector: string }): Promise => ipcRenderer.invoke('runtimeEnvironments:resolve', args), remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => diff --git a/src/renderer/src/components/settings/RuntimeHostAccessFailureAlert.tsx b/src/renderer/src/components/settings/RuntimeHostAccessFailureAlert.tsx new file mode 100644 index 00000000000..72c6525b5df --- /dev/null +++ b/src/renderer/src/components/settings/RuntimeHostAccessFailureAlert.tsx @@ -0,0 +1,69 @@ +import type { RemotePairingFailureKind } from '../../../../shared/remote-pairing-verification' +import { translate } from '@/i18n/i18n' +import { translateRemotePairingFailureDescription } from '@/lib/remote-pairing-copy' + +export type RuntimeHostAccessFailure = { + kind: RemotePairingFailureKind + message: string +} + +function failureTitle(kind: RemotePairingFailureKind): string { + switch (kind) { + case 'host-identity-mismatch': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.identityMismatch', + 'The reached Orca host does not match this access link' + ) + case 'access-link-invalid': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.invalidLink', + 'This access link is no longer valid' + ) + case 'protocol-incompatible': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.incompatible', + 'Orca versions are not compatible' + ) + case 'connection-interrupted': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.interrupted', + 'Connection interrupted' + ) + case 'environment-save-failed': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.saveFailed', + 'Could not save the host' + ) + case 'different-server': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.differentServer', + 'This link is for a different server' + ) + case 'host-unreachable': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.unavailable', + 'Host unavailable' + ) + } +} + +export function RuntimeHostAccessFailureAlert({ + id, + failure, + endpoint +}: { + id: string + failure: RuntimeHostAccessFailure + endpoint: string | null +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx b/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx index 54e31767f8c..5d4c358c229 100644 --- a/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx +++ b/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx @@ -1,7 +1,6 @@ import { ChevronDown, Loader2, Plus } from 'lucide-react' import { useMemo, useState } from 'react' import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' -import type { RemotePairingFailureKind } from '../../../../shared/remote-pairing-verification' import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Checkbox } from '../ui/checkbox' @@ -10,14 +9,12 @@ import { Label } from '../ui/label' import { translate } from '@/i18n/i18n' import { translateHostAccessLinkError, - translateRemotePairingEndpointKind, - translateRemotePairingFailureDescription + translateRemotePairingEndpointKind } from '@/lib/remote-pairing-copy' - -export type RuntimeHostAccessFailure = { - kind: RemotePairingFailureKind - message: string -} +import { + RuntimeHostAccessFailureAlert, + type RuntimeHostAccessFailure +} from './RuntimeHostAccessFailureAlert' type RuntimeHostAccessFormProps = { name: string @@ -238,51 +235,11 @@ export function RuntimeHostAccessForm({ ) : null} {failure ? ( - + failure={failure} + endpoint={parsed.ok ? parsed.value.displayEndpoint : null} + /> ) : null}
diff --git a/src/renderer/src/components/settings/RuntimeRepairDialog.tsx b/src/renderer/src/components/settings/RuntimeRepairDialog.tsx new file mode 100644 index 00000000000..5faed2753bc --- /dev/null +++ b/src/renderer/src/components/settings/RuntimeRepairDialog.tsx @@ -0,0 +1,193 @@ +import { Loader2, Link2 } from 'lucide-react' +import { useMemo, useState } from 'react' +import { toast } from 'sonner' +import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' +import { refreshRuntimeEnvironmentCatalog } from '@/runtime/runtime-environment-pairing-refresh' +import { clearRuntimeCompatibilityCache } from '@/runtime/runtime-rpc-client' +import { useAppStore } from '@/store' +import { + RuntimeHostAccessFailureAlert, + type RuntimeHostAccessFailure +} from './RuntimeHostAccessFailureAlert' + +type RuntimeRepairDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + environmentId: string + name: string +} + +/** Pastes a fresh pairing link into a saved server whose pairing it revoked. */ +export function RuntimeRepairDialog({ + open, + onOpenChange, + environmentId, + name +}: RuntimeRepairDialogProps): React.JSX.Element { + const [busy, setBusy] = useState(false) + return ( + !busy && onOpenChange(next)}> + + {/* Why inside the content: it unmounts on close, so a reopen starts from an empty link. */} + onOpenChange(false)} + /> + + + ) +} + +function RuntimeRepairForm({ + environmentId, + name, + busy, + setBusy, + onClose +}: { + environmentId: string + name: string + busy: boolean + setBusy: (busy: boolean) => void + onClose: () => void +}): React.JSX.Element { + const [accessLink, setAccessLink] = useState('') + const [failure, setFailure] = useState(null) + const mountedRef = useMountedRef() + const parsed = useMemo(() => parseHostAccessLink(accessLink), [accessLink]) + const inputError = accessLink.trim() !== '' && !parsed.ok + + const submit = async (): Promise => { + if (!parsed.ok || busy) { + return + } + setBusy(true) + setFailure(null) + try { + const result = await window.api.runtimeEnvironments.repairFromPairingCode({ + selector: environmentId, + pairingCode: accessLink.trim() + }) + if (!result.ok) { + if (mountedRef.current) { + setFailure({ kind: result.kind, message: result.message }) + } + return + } + clearRuntimeCompatibilityCache(environmentId) + await refreshRuntimeEnvironmentCatalog() + await useAppStore.getState().readRuntimeHostStatusSnapshots() + toast.success( + translate( + 'auto.components.settings.RuntimeRepairDialog.success', + 'Re-paired with {{name}}', + { + name + } + ) + ) + if (mountedRef.current) { + onClose() + } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate('auto.components.settings.RuntimeRepairDialog.failed', 'Re-pair failed') + ) + } finally { + setBusy(false) + } + } + + return ( +
{ + event.preventDefault() + void submit() + }} + > + + + {translate('auto.components.settings.RuntimeRepairDialog.title', 'Re-pair {{name}}', { + name + })} + + + {translate( + 'auto.components.settings.RuntimeRepairDialog.description', + '{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.', + { name } + )} + + +
+ + { + setFailure(null) + setAccessLink(event.target.value) + }} + placeholder={translate( + 'auto.components.settings.RuntimeHostAccessForm.accessLinkPlaceholder', + 'orca://pair?code=...' + )} + className="min-w-0 font-mono" + /> + {inputError && !parsed.ok ? ( + + ) : null} +
+ {failure ? ( + + ) : null} + + + + + + ) +} diff --git a/src/renderer/src/components/settings/runtime-server-row.tsx b/src/renderer/src/components/settings/runtime-server-row.tsx index d1859861149..c975fed1b16 100644 --- a/src/renderer/src/components/settings/runtime-server-row.tsx +++ b/src/renderer/src/components/settings/runtime-server-row.tsx @@ -1,5 +1,9 @@ -import { AlertTriangle, Loader2, Server, ServerOff, Trash2 } from 'lucide-react' -import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { useState } from 'react' +import { AlertTriangle, Link2, Loader2, Server, ServerOff, Trash2 } from 'lucide-react' +import { + isUserManagedRuntimeEnvironment, + type PublicKnownRuntimeEnvironment +} from '../../../../shared/runtime-environments' import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -25,6 +29,7 @@ import { getRemoteServerManualUpdateHelp, RemoteServerUpdateStatus } from './RemoteServerUpdateStatus' +import { RuntimeRepairDialog } from './RuntimeRepairDialog' type RuntimeServerRowProps = { environment: PublicKnownRuntimeEnvironment @@ -87,7 +92,21 @@ export function RuntimeServerRow({ runtimeStatusEntry.remoteControl ?? runtimeStatusEntry.status?.remoteControl ?? null } : details - const detailsDescription = getHostDetailsDescription(effectiveDetails) + const [repairOpen, setRepairOpen] = useState(false) + const snapshot = runtimeStatusEntry?.snapshot + const accessRevoked = + 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 connectionState = details?.status === 'loading' && !runtimeStatusEntry?.status ? 'checking' @@ -119,7 +138,7 @@ export function RuntimeServerRow({ {getRuntimeServerConnectionLabel(connectionState)} - {effectiveDetails?.compatibility?.kind === 'blocked' ? ( + {accessRevoked || effectiveDetails?.compatibility?.kind === 'blocked' ? ( ) : effectiveDetails?.status === 'loading' ? ( @@ -145,7 +164,7 @@ export function RuntimeServerRow({

) : null} + {accessRevoked && repairable ? ( + + ) : null} {isReachable ? ( + ) } diff --git a/src/renderer/src/components/settings/runtime-servers-connect-section.tsx b/src/renderer/src/components/settings/runtime-servers-connect-section.tsx index 90084aa0962..55a27b8390b 100644 --- a/src/renderer/src/components/settings/runtime-servers-connect-section.tsx +++ b/src/renderer/src/components/settings/runtime-servers-connect-section.tsx @@ -7,7 +7,8 @@ import { cn } from '@/lib/utils' import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' import { Button } from '../ui/button' import type { RuntimeHostDetails } from './runtime-environment-host-details' -import { RuntimeHostAccessForm, type RuntimeHostAccessFailure } from './RuntimeHostAccessForm' +import { RuntimeHostAccessForm } from './RuntimeHostAccessForm' +import type { RuntimeHostAccessFailure } from './RuntimeHostAccessFailureAlert' import { RuntimeServerRow } from './runtime-server-row' type RuntimeServersConnectSectionProps = { diff --git a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts index 641e0ccbd71..58472bef426 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts @@ -5,7 +5,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { isRuntimeEnvironmentRemovalBlocked } from './runtime-environment-host-details' -import type { RuntimeHostAccessFailure } from './RuntimeHostAccessForm' +import type { RuntimeHostAccessFailure } from './RuntimeHostAccessFailureAlert' type RuntimeEnvironmentMutationActionParams = { environments: PublicKnownRuntimeEnvironment[] diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx index 918ec0bc083..d766ac4ab7b 100644 --- a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx @@ -34,7 +34,7 @@ function getHostStatusDetail(host: SidebarHostOption): string { if (host.compatibility?.kind === 'blocked') { return describeRuntimeCompatBlock(host.compatibility) } - return `${getSidebarHostHealthLabel(host.health)}${host.detail ? ` - ${host.detail}` : ''}` + return `${getSidebarHostHealthLabel(host.health, host.blockedReason)}${host.detail ? ` - ${host.detail}` : ''}` } export function AddRepoHostSelector({ @@ -79,7 +79,7 @@ export function AddRepoHostSelector({ title={getHostStatusDetail(selectedHost)} className="shrink-0 text-[11px] font-normal text-muted-foreground" > - {getSidebarHostHealthLabel(selectedHost.health)} + {getSidebarHostHealthLabel(selectedHost.health, selectedHost.blockedReason)} ) : null} diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx index 824a88abfa3..b66461b3697 100644 --- a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx +++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react' import { AlertTriangle, Ellipsis, + Link2, Loader2, Pencil, Plug, @@ -34,6 +35,7 @@ import type { HostHeaderRow } from '../../../../shared/sidebar-list/host-section import { buildHostHeaderMenuModel } from './host-header-menu-items' import { HostRenameDialog } from './HostRenameDialog' import { HostRemoveDialog } from './HostRemoveDialog' +import { RuntimeRepairDialog } from '../settings/RuntimeRepairDialog' import { resolveHostRemoval } from './host-rename-remove' function blockedTitle(reason: 'client-too-old' | 'server-too-old'): string { @@ -72,6 +74,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS const [busy, setBusy] = useState(false) const [renameOpen, setRenameOpen] = useState(false) const [removeOpen, setRemoveOpen] = useState(false) + const [repairOpen, setRepairOpen] = useState(false) const mountedRef = useMountedRef() const sshStatus = useAppStore((s) => { const parsed = parseExecutionHostId(row.hostId) @@ -85,9 +88,12 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS kind: row.kind, health: row.health, sshConnected: sshStatus === 'connected', - compatibility: row.compatibility + compatibility: row.compatibility, + blockedReason: row.blockedReason }) const removalTarget = resolveHostRemoval(row.hostId) + const parsedHost = parseExecutionHostId(row.hostId) + const runtimeEnvironmentId = parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null const handleManage = useCallback(() => { openManageHost(row) @@ -204,6 +210,18 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS + {model.actions.includes('runtime-repair') && ( + <> + setRepairOpen(true)} + > + + {translate('auto.components.sidebar.HostSectionHeaderMenu.repair', 'Re-pair…')} + + + + )} {model.blocked && ( <> @@ -280,6 +298,14 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS hostId={row.hostId} derivedLabel={row.label} /> + {runtimeEnvironmentId && ( + + )} {removalTarget && ( { }) expect(model.blocked).toBeNull() }) + + it('leads with Re-pair for a runtime host that revoked this client', () => { + const model = buildHostHeaderMenuModel({ + kind: 'runtime', + health: 'blocked', + blockedReason: 'access-revoked' + }) + expect(model.actions).toEqual([ + 'runtime-repair', + 'rename', + 'runtime-check-connection', + 'manage', + 'remove' + ]) + expect(model.blocked).toBeNull() + }) + + it('offers Re-pair only for an access-revoked runtime host', () => { + expect( + buildHostHeaderMenuModel({ + kind: 'runtime', + health: 'blocked', + blockedReason: 'incompatible' + }).actions + ).not.toContain('runtime-repair') + expect( + buildHostHeaderMenuModel({ + kind: 'runtime', + health: 'available', + blockedReason: 'access-revoked' + }).actions + ).not.toContain('runtime-repair') + expect( + buildHostHeaderMenuModel({ + kind: 'ssh', + health: 'blocked', + blockedReason: 'access-revoked' + }).actions + ).not.toContain('runtime-repair') + }) }) 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 2117d43fc24..309917a4c9d 100644 --- a/src/renderer/src/components/sidebar/host-header-menu-items.ts +++ b/src/renderer/src/components/sidebar/host-header-menu-items.ts @@ -1,6 +1,7 @@ import type { ExecutionHostKind } from '../../../../shared/execution-host' import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry' import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' +import type { RuntimeHostBlockedReason } from '../../../../shared/runtime-host-status' // Why: the host-header dropdown shows different lifecycle actions per host kind. // Keeping the availability rules in a pure function makes them unit-testable @@ -9,6 +10,7 @@ import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' // control (the design doc forbids a separate focused-host toggle), and // decluttering is served by collapsing the section. export type HostHeaderMenuAction = + | 'runtime-repair' | 'rename' | 'manage' | 'ssh-reconnect' @@ -31,6 +33,7 @@ export type HostHeaderMenuInput = { /** SSH connection status drives Reconnect vs Disconnect. */ sshConnected?: boolean compatibility?: RuntimeCompatVerdict + blockedReason?: RuntimeHostBlockedReason } function sshActions(connected: boolean): HostHeaderMenuAction[] { @@ -40,9 +43,18 @@ function sshActions(connected: boolean): HostHeaderMenuAction[] { } export function buildHostHeaderMenuModel(input: HostHeaderMenuInput): HostHeaderMenuModel { + const actions: HostHeaderMenuAction[] = [] + // Why first: a revoked pairing is the one fix the user came to the menu for. + if ( + input.kind === 'runtime' && + input.health === 'blocked' && + input.blockedReason === 'access-revoked' + ) { + actions.push('runtime-repair') + } // Why: Rename edits only the client-side display label, so it's offered for // every host kind including local. - const actions: HostHeaderMenuAction[] = ['rename'] + actions.push('rename') switch (input.kind) { case 'ssh': @@ -65,7 +77,9 @@ export function buildHostHeaderMenuModel(input: HostHeaderMenuInput): HostHeader } const blocked = - input.health === 'blocked' && input.compatibility?.kind === 'blocked' + input.health === 'blocked' && + input.blockedReason !== 'access-revoked' && + input.compatibility?.kind === 'blocked' ? { reason: input.compatibility.reason } : null diff --git a/src/renderer/src/components/sidebar/host-section-rows.test.ts b/src/renderer/src/components/sidebar/host-section-rows.test.ts index 5d810d4db4a..2fd32c714a7 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.test.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.test.ts @@ -684,7 +684,7 @@ describe('addHostSectionRows', () => { }) }) - it('passes host kind and blocked compatibility through to the header row', () => { + it('passes host kind, blocked compatibility and reason through to the header row', () => { const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' } const project = repo('runtime-project') const rows = [ @@ -716,7 +716,8 @@ describe('addHostSectionRows', () => { clientProtocolVersion: 5, serverProtocolVersion: 1, requiredServerProtocolVersion: 4 - } + }, + blockedReason: 'incompatible' } ], workspaceHostScope: 'all', @@ -728,7 +729,8 @@ describe('addHostSectionRows', () => { ).toMatchObject({ kind: 'runtime', health: 'blocked', - compatibility: { kind: 'blocked', reason: 'server-too-old' } + compatibility: { kind: 'blocked', reason: 'server-too-old' }, + blockedReason: 'incompatible' }) }) diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts index 7b180666a8d..945ef7c5254 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -217,6 +217,8 @@ describe('sidebar host options', () => { expect(getSidebarHostHealthLabel('available')).toBe('Connected') expect(getSidebarHostHealthLabel('connecting')).toBe('Connecting') expect(getSidebarHostHealthLabel('blocked')).toBe('Update needed') + expect(getSidebarHostHealthLabel('blocked', 'incompatible')).toBe('Update needed') + expect(getSidebarHostHealthLabel('blocked', 'access-revoked')).toBe('Re-pair needed') expect(getSidebarHostHealthLabel('error')).toBe('Needs attention') }) diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts index 60910d0e7c9..b69d88c3353 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts @@ -20,7 +20,10 @@ import { isWslHostRuntimeEnvironment, type PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' -import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' +import type { + RuntimeEnvironmentStatus, + RuntimeHostBlockedReason +} from '../../../../shared/runtime-host-status' import type { WslHostStatus } from '../../../../shared/wsl-host-types' import { translate } from '@/i18n/i18n' import { @@ -39,6 +42,7 @@ export type SidebarHostOption = { presence: 'local' | 'configured' | 'project' | 'active' // Why: surfaced to the sidebar host-header menu so it can warn on version skew. compatibility?: RuntimeCompatVerdict + blockedReason?: RuntimeHostBlockedReason // Why: lets host headers spell out auth-needed SSH states, not just an icon. connectionStatus?: SshConnectionStatus aliasHostIds?: readonly ExecutionHostId[] @@ -200,6 +204,9 @@ export function getSidebarHostVisibilityLabel( ) } -export function getSidebarHostHealthLabel(health: SidebarHostScopeOption['health']): string { - return health === 'mixed' ? 'Mixed' : getExecutionHostHealthLabel(health) +export function getSidebarHostHealthLabel( + health: SidebarHostScopeOption['health'], + blockedReason?: RuntimeHostBlockedReason +): string { + return health === 'mixed' ? 'Mixed' : getExecutionHostHealthLabel(health, blockedReason) } diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx index ad87f60c2d3..a88dd81ec28 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx @@ -2,9 +2,10 @@ import React from 'react' import { AlertTriangle, ChevronDown, Loader2, Server, ServerOff } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' import type { HostHeaderRow } from '../../../../../../shared/sidebar-list/host-section-rows' import { HostSectionHeaderMenu } from '../../HostSectionHeaderMenu' +import { stopRepoHeaderMenuEvent } from './header-event-guards' +import { getHostHeaderDetail } from './host-header-detail' function formatSectionActivityLabel(count: number, label: string): string { return `${count} ${label}${count === 1 ? '' : 's'}` @@ -47,37 +48,6 @@ function HostHeaderHealthIcon({ return null } -function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null { - // Why: a blocked compatibility verdict earns a compact warning so the host stands out. - if (row.health === 'blocked') { - return { - text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'), - isWarning: true - } - } - // Why: auth-failed needs a worded status; the health icon alone doesn't tell the user to re-auth. - if (row.connectionStatus === 'auth-failed') { - return { - text: translate( - 'auto.components.sidebar.WorktreeList.hostAuthNeeded', - 'Authentication needed' - ), - isWarning: true - } - } - if (row.health === 'disconnected') { - return { - text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'), - isWarning: false - } - } - // Why: show the transport detail only for remote hosts; it's noise under the local label. - if (row.kind !== 'local') { - return { text: row.detail, isWarning: false } - } - return null -} - export function HostSectionHeader({ row, onToggle, @@ -126,7 +96,7 @@ export function HostSectionHeader({ )} {/* Why: badge hugs the label (like repo headers) instead of floating by the hover controls. */} -

+
- + {/* Why: menu and dialog portals still bubble React events here; keep them from toggling or dragging the row. */} +
diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.test.ts b/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.test.ts new file mode 100644 index 00000000000..491845244fd --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { getHostHeaderDetail } from './host-header-detail' + +const runtimeRow = { kind: 'runtime', detail: 'Orca server' } as const + +describe('getHostHeaderDetail', () => { + it('asks for a re-pair when the host revoked this client', () => { + expect( + getHostHeaderDetail({ + ...runtimeRow, + health: 'blocked', + blockedReason: 'access-revoked' + }) + ).toEqual({ text: 'Re-pair required', isWarning: true }) + }) + + it('keeps the update label for an incompatible or unlabeled block', () => { + expect( + getHostHeaderDetail({ + ...runtimeRow, + health: 'blocked', + blockedReason: 'incompatible' + }) + ).toEqual({ text: 'Update required', isWarning: true }) + expect(getHostHeaderDetail({ ...runtimeRow, health: 'blocked' })).toEqual({ + text: 'Update required', + isWarning: true + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts b/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts new file mode 100644 index 00000000000..7386c76f9ec --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts @@ -0,0 +1,45 @@ +import { translate } from '@/i18n/i18n' +import type { HostHeaderRow } from '../../../../../../shared/sidebar-list/host-section-rows' + +export function getHostHeaderDetail( + row: Pick +): { text: string; isWarning: boolean } | null { + // Why: a host that revoked this client's pairing needs a new link, not an update. + if (row.health === 'blocked' && row.blockedReason === 'access-revoked') { + return { + text: translate( + 'auto.components.sidebar.WorktreeList.hostRepairRequired', + 'Re-pair required' + ), + isWarning: true + } + } + // Why: a blocked compatibility verdict earns a compact warning so the host stands out. + if (row.health === 'blocked') { + return { + text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'), + isWarning: true + } + } + // Why: auth-failed needs a worded status; the health icon alone doesn't tell the user to re-auth. + if (row.connectionStatus === 'auth-failed') { + return { + text: translate( + 'auto.components.sidebar.WorktreeList.hostAuthNeeded', + 'Authentication needed' + ), + isWarning: true + } + } + if (row.health === 'disconnected') { + return { + text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'), + isWarning: false + } + } + // Why: show the transport detail only for remote hosts; it's noise under the local label. + if (row.kind !== 'local') { + return { text: row.detail, isWarning: false } + } + return null +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f0286bea6cf..b099d344ca2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -567,7 +567,8 @@ "loopbackPairingBlocked": "This access link points back to this device.", "remotePairingUnreachable": "Cannot reach Orca at {{endpoint}}.", "remotePairingInvalidDetails": "This access link contains invalid connection details.", - "remotePairingSaveFailed": "Orca verified the host but could not save it. Check browser storage and try again." + "remotePairingSaveFailed": "Orca verified the host but could not save it. Check browser storage and try again.", + "remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again." }, "web": { "preload": { @@ -6139,7 +6140,8 @@ "groupRenameFailedDesc": "Orca could not confirm the new name with the group's host. Recheck the group after reconnecting.", "failedNestWorkspace": "Failed to nest workspace", "sidebarRowMissing": "Target no longer exists", - "failedUnnestWorkspace": "Failed to unnest workspace" + "failedUnnestWorkspace": "Failed to unnest workspace", + "hostRepairRequired": "Re-pair required" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancel", @@ -6466,7 +6468,8 @@ "59b553e2aa": "Disconnect", "2d3e4f5a6b": "Check connection", "3c4d5e6f7a": "Manage host…", - "6e7f8a9b0c": "Remove host…" + "6e7f8a9b0c": "Remove host…", + "repair": "Re-pair…" }, "LinearAgentSkillSetupPrompt": { "modalTitle": "Enable Linear ticket access", @@ -8675,7 +8678,9 @@ "troubleshootStepRegenerate": "Generate a new access link and use only the newest link here.", "troubleshootTunnel": "Using an SSH local forward? Return to Connect to a host, paste the loopback link, then enable “I am using an SSH tunnel” under Advanced.", "cloudVmWorkflow": "Cloud VM", - "cloudVmWorkflowHelp": "Manage recipe-created cloud machines" + "cloudVmWorkflowHelp": "Manage recipe-created cloud machines", + "accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.", + "repair": "Re-pair…" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copy {{value0}}" @@ -12320,7 +12325,9 @@ "networkConnection": "Network connection", "notAttempted": "Not attempted", "saveFailed": "Could not save the host", - "saveFailedHelp": "The host was verified, but Orca could not save it. Check the name and local settings storage, then try again." + "saveFailedHelp": "The host was verified, but Orca could not save it. Check the name and local settings storage, then try again.", + "differentServer": "This link is for a different server", + "differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing." }, "CloudVmSetupGuide": { "title": "Create a Cloud VM", @@ -12908,6 +12915,13 @@ "confirm": "Restart", "terminalsUnknown": "Orca couldn’t confirm whether terminals are running on this host, so it didn’t restart the managed server.", "terminalsRunning": "{{count}} terminals are still running on this host. Close them, then restart the managed server." + }, + "RuntimeRepairDialog": { + "title": "Re-pair {{name}}", + "description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.", + "submit": "Re-pair", + "success": "Re-paired with {{name}}", + "failed": "Re-pair failed" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e6fd77516b2..c32268b3f3b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -387,7 +387,8 @@ "loopbackPairingBlocked": "Este enlace de acceso apunta de nuevo a este dispositivo.", "remotePairingUnreachable": "No se puede acceder a Orca en {{endpoint}}.", "remotePairingInvalidDetails": "Este enlace de acceso contiene datos de conexión no válidos.", - "remotePairingSaveFailed": "Orca verificó al anfitrión, pero no pudo guardarlo. Compruebe el almacenamiento del navegador e inténtelo de nuevo." + "remotePairingSaveFailed": "Orca verificó al anfitrión, pero no pudo guardarlo. Compruebe el almacenamiento del navegador e inténtelo de nuevo.", + "remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again." }, "web": { "preload": { @@ -5866,7 +5867,8 @@ "sidebarRowMissing": "El destino ya no existe", "failedUnnestWorkspace": "Error al expandir el espacio de trabajo", "groupRenameFailed": "No se pudo cambiar el nombre del grupo", - "groupRenameFailedDesc": "Orca no pudo confirmar el nuevo nombre con el anfitrión del grupo. Vuelva a verificar el grupo después de volver a conectarse." + "groupRenameFailedDesc": "Orca no pudo confirmar el nuevo nombre con el anfitrión del grupo. Vuelva a verificar el grupo después de volver a conectarse.", + "hostRepairRequired": "Re-pair required" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancelar", @@ -6290,7 +6292,8 @@ "59b553e2aa": "Desconectar", "2d3e4f5a6b": "Verificar conexión", "3c4d5e6f7a": "Administrar host…", - "6e7f8a9b0c": "Quitar host…" + "6e7f8a9b0c": "Quitar host…", + "repair": "Re-pair…" }, "WorkspaceKanbanDrawer": { "1975a4e480": "Falló la sincronización del estado de la tarea", @@ -8383,7 +8386,9 @@ "serverWorkspaceWindowClosed": "Ventana del espacio de trabajo cerrada", "serverRuntimeUnavailable": "Orca no disponible", "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" + "serverReconnecting": "Reconectando", + "accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.", + "repair": "Re-pair…" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copiar {{value0}}" @@ -12074,7 +12079,9 @@ "networkConnection": "Conexión de red", "notAttempted": "No se intentó", "saveFailed": "No se pudo guardar el host", - "saveFailedHelp": "El anfitrión fue verificado, pero Orca no pudo salvarlo. Verifique el nombre y el almacenamiento de la configuración local y vuelva a intentarlo." + "saveFailedHelp": "El anfitrión fue verificado, pero Orca no pudo salvarlo. Verifique el nombre y el almacenamiento de la configuración local y vuelva a intentarlo.", + "differentServer": "This link is for a different server", + "differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing." }, "CloudVmSetupGuide": { "title": "Crear una VM en la nube", @@ -12644,6 +12651,13 @@ "remove": "Quitar", "turnOn": "Activar en Experimental", "forceUpdate": "Forzar actualización" + }, + "RuntimeRepairDialog": { + "title": "Re-pair {{name}}", + "description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.", + "submit": "Re-pair", + "success": "Re-paired with {{name}}", + "failed": "Re-pair failed" } }, "right": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 42fc8dd87f2..f2bee94ca6f 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -491,7 +491,8 @@ "loopbackPairingBlocked": "Ce lien d'accès pointe vers cet appareil lui-même.", "remotePairingUnreachable": "Impossible de joindre Orca à l'adresse {{endpoint}}.", "remotePairingInvalidDetails": "Ce lien d'accès contient des détails de connexion invalides.", - "remotePairingSaveFailed": "Orca a vérifié l'hôte mais n'a pas pu l'enregistrer. Vérifiez le stockage du navigateur et réessayez." + "remotePairingSaveFailed": "Orca a vérifié l'hôte mais n'a pas pu l'enregistrer. Vérifiez le stockage du navigateur et réessayez.", + "remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again." }, "web": { "preload": { @@ -6006,7 +6007,8 @@ "groupRenameFailedDesc": "Orca n'a pas pu confirmer le nouveau nom auprès de l'hôte du groupe. Revérifiez le groupe après reconnexion.", "failedNestWorkspace": "Échec de l'imbrication de l'espace de travail", "sidebarRowMissing": "La cible n'existe plus", - "failedUnnestWorkspace": "Échec de la désimbrication de l'espace de travail" + "failedUnnestWorkspace": "Échec de la désimbrication de l'espace de travail", + "hostRepairRequired": "Re-pair required" }, "WorktreeMetaDialog": { "3db0a2a593": "Annuler", @@ -6332,7 +6334,8 @@ "59b553e2aa": "Déconnecter", "2d3e4f5a6b": "Vérifier la connexion", "3c4d5e6f7a": "Gérer l'hôte…", - "6e7f8a9b0c": "Retirer l'hôte…" + "6e7f8a9b0c": "Retirer l'hôte…", + "repair": "Re-pair…" }, "LinearAgentSkillSetupPrompt": { "modalTitle": "Activer l'accès aux tickets Linear", @@ -8534,7 +8537,9 @@ "serverWorkspaceWindowClosed": "Fenêtre de l'espace de travail fermée", "serverRuntimeUnavailable": "Orque indisponible", "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" + "serverReconnecting": "Reconnexion", + "accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.", + "repair": "Re-pair…" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copier {{value0}}" @@ -12178,7 +12183,9 @@ "networkConnection": "Connexion réseau", "notAttempted": "Non tenté", "saveFailed": "Impossible d'enregistrer l'hôte", - "saveFailedHelp": "L'hôte a été vérifié, mais Orca n'a pas pu l'enregistrer. Vérifiez le nom et le stockage local des paramètres, puis réessayez." + "saveFailedHelp": "L'hôte a été vérifié, mais Orca n'a pas pu l'enregistrer. Vérifiez le nom et le stockage local des paramètres, puis réessayez.", + "differentServer": "This link is for a different server", + "differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing." }, "CloudVmSetupGuide": { "title": "Créer une VM cloud", @@ -12757,6 +12764,13 @@ "remove": "Supprimer", "turnOn": "Activer dans Expérimental", "forceUpdate": "Forcer la mise à jour" + }, + "RuntimeRepairDialog": { + "title": "Re-pair {{name}}", + "description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.", + "submit": "Re-pair", + "success": "Re-paired with {{name}}", + "failed": "Re-pair failed" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6a24ae44e5c..c41dd36c23e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -387,7 +387,8 @@ "loopbackPairingBlocked": "このアクセスリンクはこのデバイス自身を指しています。", "remotePairingUnreachable": "{{endpoint}} の Orca に接続できません。", "remotePairingInvalidDetails": "このアクセスリンクには無効な接続情報が含まれています。", - "remotePairingSaveFailed": "Orca はホストを確認しましたが、ホストを保存できませんでした。ブラウザのストレージを確認して、もう一度試してください。" + "remotePairingSaveFailed": "Orca はホストを確認しましたが、ホストを保存できませんでした。ブラウザのストレージを確認して、もう一度試してください。", + "remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again." }, "web": { "preload": { @@ -5852,7 +5853,8 @@ "sidebarRowMissing": "対象はもう存在しません", "failedUnnestWorkspace": "ワークスペースの展開に失敗しました", "groupRenameFailed": "グループ名の変更に失敗しました", - "groupRenameFailedDesc": "Orca はグループのホストに新規名前を確認できませんでした。再接続後、グループを再確認してください。" + "groupRenameFailedDesc": "Orca はグループのホストに新規名前を確認できませんでした。再接続後、グループを再確認してください。", + "hostRepairRequired": "Re-pair required" }, "WorktreeMetaDialog": { "3db0a2a593": "キャンセル", @@ -6295,7 +6297,8 @@ "59b553e2aa": "切断", "2d3e4f5a6b": "接続を確認", "3c4d5e6f7a": "ホストを管理…", - "6e7f8a9b0c": "ホストを削除…" + "6e7f8a9b0c": "ホストを削除…", + "repair": "Re-pair…" }, "WorkspaceKanbanDrawer": { "1975a4e480": "タスクステータスの同期に失敗しました", @@ -8410,7 +8413,9 @@ "serverWorkspaceWindowClosed": "ワークスペースウィンドウを閉じた", "serverRuntimeUnavailable": "Orca は利用できません", "serverRuntimeUnavailableDescription": "SSH トランスポートは接続されていますが、Orca ランタイムが応答しませんでした。ホストがまだ実行中の可能性があります。", - "serverReconnecting": "再接続中" + "serverReconnecting": "再接続中", + "accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.", + "repair": "Re-pair…" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "{{value0}}をコピー" @@ -12079,7 +12084,9 @@ "networkConnection": "ネットワーク接続", "notAttempted": "未試行", "saveFailed": "ホストを保存できませんでした", - "saveFailedHelp": "ホストは確認されましたが、Orca はホストを保存できませんでした。名前とローカル設定ストレージを確認して、再試行してください。" + "saveFailedHelp": "ホストは確認されましたが、Orca はホストを保存できませんでした。名前とローカル設定ストレージを確認して、再試行してください。", + "differentServer": "This link is for a different server", + "differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing." }, "CloudVmSetupGuide": { "title": "クラウド VM を作成", @@ -12649,6 +12656,13 @@ "remove": "削除", "turnOn": "実験的機能でオンにする", "forceUpdate": "強制更新" + }, + "RuntimeRepairDialog": { + "title": "Re-pair {{name}}", + "description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.", + "submit": "Re-pair", + "success": "Re-paired with {{name}}", + "failed": "Re-pair failed" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0353b159bb0..a27fa178a67 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -387,7 +387,8 @@ "loopbackPairingBlocked": "이 액세스 링크는 이 기기 자체를 가리킵니다.", "remotePairingUnreachable": "{{endpoint}}의 Orca에 연결할 수 없습니다.", "remotePairingInvalidDetails": "이 액세스 링크에 잘못된 연결 정보가 포함되어 있습니다.", - "remotePairingSaveFailed": "Orca가 호스트를 확인했지만 저장할 수 없습니다. 브라우저 저장 공간을 확인하고 다시 시도하세요." + "remotePairingSaveFailed": "Orca가 호스트를 확인했지만 저장할 수 없습니다. 브라우저 저장 공간을 확인하고 다시 시도하세요.", + "remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again." }, "web": { "preload": { @@ -5852,7 +5853,8 @@ "sidebarRowMissing": "대상이 더 이상 존재하지 않습니다", "failedUnnestWorkspace": "워크트리 펼치기 실패", "groupRenameFailed": "그룹 이름을 바꾸지 못했습니다.", - "groupRenameFailedDesc": "Orca는 그룹 호스트에게 새 이름을 확인할 수 없습니다. 다시 연결한 후 그룹을 다시 확인하세요." + "groupRenameFailedDesc": "Orca는 그룹 호스트에게 새 이름을 확인할 수 없습니다. 다시 연결한 후 그룹을 다시 확인하세요.", + "hostRepairRequired": "Re-pair required" }, "WorktreeMetaDialog": { "3db0a2a593": "취소", @@ -6295,7 +6297,8 @@ "59b553e2aa": "연결 해제", "2d3e4f5a6b": "연결 확인", "3c4d5e6f7a": "호스트 관리…", - "6e7f8a9b0c": "호스트 제거…" + "6e7f8a9b0c": "호스트 제거…", + "repair": "Re-pair…" }, "WorkspaceKanbanDrawer": { "1975a4e480": "작업 상태 동기화 실패", @@ -8373,7 +8376,9 @@ "serverWorkspaceWindowClosed": "워크스페이스 창이 닫혔습니다.", "serverRuntimeUnavailable": "Orca를 사용할 수 없습니다.", "serverRuntimeUnavailableDescription": "SSH 전송이 연결되었지만 Orca 런타임이 응답하지 않았습니다. 호스트가 아직 실행 중일 수 있습니다.", - "serverReconnecting": "다시 연결하는 중" + "serverReconnecting": "다시 연결하는 중", + "accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.", + "repair": "Re-pair…" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "{{value0}} 복사" @@ -12079,7 +12084,9 @@ "networkConnection": "네트워크 연결", "notAttempted": "시도하지 않음", "saveFailed": "호스트를 저장할 수 없습니다.", - "saveFailedHelp": "호스트가 확인되었지만 Orca가 호스트를 저장할 수 없습니다. 이름과 로컬 설정 저장소를 확인한 후 다시 시도하세요." + "saveFailedHelp": "호스트가 확인되었지만 Orca가 호스트를 저장할 수 없습니다. 이름과 로컬 설정 저장소를 확인한 후 다시 시도하세요.", + "differentServer": "This link is for a different server", + "differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing." }, "CloudVmSetupGuide": { "title": "클라우드 VM 만들기", @@ -12649,6 +12656,13 @@ "remove": "제거", "turnOn": "실험적에서 켜기", "forceUpdate": "강제 업데이트" + }, + "RuntimeRepairDialog": { + "title": "Re-pair {{name}}", + "description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.", + "submit": "Re-pair", + "success": "Re-paired with {{name}}", + "failed": "Re-pair failed" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index bffa9b41c59..42a4897b094 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -387,7 +387,8 @@ "loopbackPairingBlocked": "此访问链接会指回此设备。", "remotePairingUnreachable": "无法连接 {{endpoint}} 上的 Orca。", "remotePairingInvalidDetails": "此访问链接包含无效的连接信息。", - "remotePairingSaveFailed": "Orca 验证了主机但无法保存它。检查浏览器存储并重试。" + "remotePairingSaveFailed": "Orca 验证了主机但无法保存它。检查浏览器存储并重试。", + "remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again." }, "web": { "preload": { @@ -5852,7 +5853,8 @@ "sidebarRowMissing": "目标不再存在", "failedUnnestWorkspace": "展开工作区失败", "groupRenameFailed": "重命名组失败", - "groupRenameFailedDesc": "Orca 无法与该群组的主持人确认新名称。重新连接后重新检查该组。" + "groupRenameFailedDesc": "Orca 无法与该群组的主持人确认新名称。重新连接后重新检查该组。", + "hostRepairRequired": "Re-pair required" }, "WorktreeMetaDialog": { "3db0a2a593": "取消", @@ -6295,7 +6297,8 @@ "59b553e2aa": "断开连接", "2d3e4f5a6b": "检查连接", "3c4d5e6f7a": "管理主机…", - "6e7f8a9b0c": "删除主机..." + "6e7f8a9b0c": "删除主机...", + "repair": "Re-pair…" }, "WorkspaceKanbanDrawer": { "1975a4e480": "任务状态同步失败", @@ -8373,7 +8376,9 @@ "serverWorkspaceWindowClosed": "工作区窗口已关闭", "serverRuntimeUnavailable": "Orca 不可用", "serverRuntimeUnavailableDescription": "SSH 传输已连接,但 Orca 运行时未应答。主机可能仍在运行。", - "serverReconnecting": "重新连接" + "serverReconnecting": "重新连接", + "accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.", + "repair": "Re-pair…" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "复制 {{value0}}" @@ -12079,7 +12084,9 @@ "networkConnection": "网络连接", "notAttempted": "未尝试", "saveFailed": "无法保存主机", - "saveFailedHelp": "主机已验证,但 Orca 无法保存。检查名称和本地设置存储,然后重试。" + "saveFailedHelp": "主机已验证,但 Orca 无法保存。检查名称和本地设置存储,然后重试。", + "differentServer": "This link is for a different server", + "differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing." }, "CloudVmSetupGuide": { "title": "创建云虚拟机", @@ -12649,6 +12656,13 @@ "remove": "移除", "turnOn": "在实验性中开启", "forceUpdate": "强制更新" + }, + "RuntimeRepairDialog": { + "title": "Re-pair {{name}}", + "description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.", + "submit": "Re-pair", + "success": "Re-paired with {{name}}", + "failed": "Re-pair failed" } }, "right": { diff --git a/src/renderer/src/lib/remote-pairing-copy.ts b/src/renderer/src/lib/remote-pairing-copy.ts index 72d74ad494b..aaa6e8b2405 100644 --- a/src/renderer/src/lib/remote-pairing-copy.ts +++ b/src/renderer/src/lib/remote-pairing-copy.ts @@ -81,6 +81,11 @@ export function translateRemotePairingFailureDescription( 'auto.components.settings.RuntimeHostAccessForm.saveFailedHelp', 'The host was verified, but Orca could not save it. Check the name and local settings storage, then try again.' ) + case 'different-server': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.differentServerHelp', + 'This pairing link is for a different server. Create the link on the server you are re-pairing.' + ) case 'host-unreachable': return translate( 'auto.components.settings.RuntimeHostAccessForm.unavailableHelp', diff --git a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts index 6901574bfb2..01a77188a50 100644 --- a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts +++ b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts @@ -168,6 +168,15 @@ export function createRuntimeEnvironmentsApi(): NonNullable< runtimeStatus } }, + // Why: the browser keeps a single paired server; pairing it again replaces that one. + repairFromPairingCode: async () => ({ + ok: false, + kind: 'environment-save-failed', + message: translate( + 'auto.web.webPreloadApi.remotePairingRepairUnavailable', + 'Re-pairing is not available in the browser. Pair this browser with the server again.' + ) + }), resolve: async ({ selector }) => redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)), remove: async ({ selector }) => { diff --git a/src/shared/execution-host-health.ts b/src/shared/execution-host-health.ts index 4d8b12eb255..d9e95637868 100644 --- a/src/shared/execution-host-health.ts +++ b/src/shared/execution-host-health.ts @@ -1,4 +1,5 @@ import type { SshConnectionStatus } from './ssh-types' +import type { RuntimeHostBlockedReason } from './runtime-host-status' export type ExecutionHostHealth = | 'local' @@ -29,7 +30,10 @@ export function getSshConnectionHealth( } } -export function getExecutionHostHealthLabel(health: ExecutionHostHealth): string { +export function getExecutionHostHealthLabel( + health: ExecutionHostHealth, + blockedReason?: RuntimeHostBlockedReason +): string { switch (health) { case 'local': return 'Local' @@ -38,7 +42,7 @@ export function getExecutionHostHealthLabel(health: ExecutionHostHealth): string case 'connecting': return 'Connecting' case 'blocked': - return 'Update needed' + return blockedReason === 'access-revoked' ? 'Re-pair needed' : 'Update needed' case 'disconnected': return 'Disconnected' case 'error': diff --git a/src/shared/execution-host-registry-shared-control-connect.test.ts b/src/shared/execution-host-registry-shared-control-connect.test.ts index 9d0aed0e43a..34512bad7f3 100644 --- a/src/shared/execution-host-registry-shared-control-connect.test.ts +++ b/src/shared/execution-host-registry-shared-control-connect.test.ts @@ -33,6 +33,10 @@ function diagnostics( // Mirrors applyRuntimeHostStatusSnapshot: only a verified, unretired snapshot projects `status`. function healthFor(snapshot: RuntimeHostStatusSnapshot): string | undefined { + return hostFor(snapshot)?.health +} + +function hostFor(snapshot: RuntimeHostStatusSnapshot) { const status = snapshot.verification === 'verified' && !snapshot.retired && snapshot.status ? { ...snapshot.status, remoteControl: snapshot.remoteControl ?? undefined } @@ -52,7 +56,7 @@ function healthFor(snapshot: RuntimeHostStatusSnapshot): string | undefined { } ] ]) - }).find((host) => host.id === 'runtime:env-1')?.health + }).find((host) => host.id === 'runtime:env-1') } function snapshot(patch: Partial): RuntimeHostStatusSnapshot { @@ -124,4 +128,26 @@ describe('runtime host health while shared control connects (#10704)', () => { expect(healthFor(latest())).toBe('connecting') owner.dispose() }) + + it('carries why a blocked host refused this client', () => { + expect( + hostFor(snapshot({ verification: 'blocked', blockedReason: 'access-revoked' })) + ).toMatchObject({ health: 'blocked', blockedReason: 'access-revoked' }) + expect( + hostFor(snapshot({ verification: 'blocked', blockedReason: 'incompatible' })) + ).toMatchObject({ health: 'blocked', blockedReason: 'incompatible' }) + expect( + hostFor( + snapshot({ + status: { ...STATUS, runtimeProtocolVersion: 0, protocolVersion: 0 } + }) + ) + ).toMatchObject({ health: 'blocked', blockedReason: 'incompatible' }) + expect(hostFor(snapshot({}))).not.toHaveProperty('blockedReason') + const retired = hostFor( + snapshot({ verification: 'blocked', retired: true, transport: 'disconnected' }) + ) + expect(retired).toMatchObject({ health: 'disconnected' }) + expect(retired).not.toHaveProperty('blockedReason') + }) }) diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index dc2f98bc28a..bb0d6d92d8d 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnvironmentStatus } from './runtime-host-status' +import type { RuntimeEnvironmentStatus, RuntimeHostBlockedReason } from './runtime-host-status' import { LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel, @@ -36,6 +36,8 @@ export type ExecutionHostRegistryEntry = { health: ExecutionHostHealth connectionStatus?: SshConnectionStatus compatibility?: RuntimeCompatVerdict + /** Present only while health is blocked; local evidence, never published to peers. */ + blockedReason?: RuntimeHostBlockedReason capabilities?: readonly string[] appVersion?: string | null protocolVersion?: number | null @@ -160,12 +162,18 @@ function addRuntimeHost( snapshot?.transport === 'connecting' ? 'connecting' : runtimeControlHealth(remoteControl) + const health = controlHealth ?? runtimeHealth(status, compatibility, remoteControl) + const blockedReason = + health === 'blocked' + ? (snapshot?.verification === 'blocked' && snapshot.blockedReason) || 'incompatible' + : undefined setHost(hosts, { id: hostId, kind: 'runtime', label, detail: 'Orca server', - health: controlHealth ?? runtimeHealth(status, compatibility, remoteControl), + health, + ...(blockedReason ? { blockedReason } : {}), compatibility: compatibility ?? undefined, capabilities: metadata?.capabilities, appVersion: runtimeStatus?.appVersion ?? metadata?.appVersion ?? null, diff --git a/src/shared/remote-pairing-verification.ts b/src/shared/remote-pairing-verification.ts index 47475144b0a..578db956739 100644 --- a/src/shared/remote-pairing-verification.ts +++ b/src/shared/remote-pairing-verification.ts @@ -10,6 +10,8 @@ export type RemotePairingFailureKind = | 'protocol-incompatible' | 'connection-interrupted' | 'environment-save-failed' + /** A re-pair link that reaches a server other than the saved one. */ + | 'different-server' export type RemotePairingFailure = { ok: false diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 2315a969347..3f431be4f18 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -104,7 +104,7 @@ export function removeEnvironment(userDataPath: string, selector: string): Known export function updateEnvironmentFromPairingCode( userDataPath: string, selector: string, - args: { pairingCode: string; now?: number } + args: { pairingCode: string; now?: number; runtimeId?: string } ): KnownRuntimeEnvironment { const offer = parseRemotePairingCode(userDataPath, args.pairingCode) assertNoIndependentSshAccess( @@ -119,7 +119,7 @@ export function updateEnvironmentFromPairingCode( name: existing.name, now: existing.createdAt, offer, - runtimeId: existing.runtimeId, + runtimeId: args.runtimeId ?? existing.runtimeId, ...(existing.source ? { source: existing.source } : {}), ...getPairingConnectionDependency(existing.connectionDependency, offer) }) diff --git a/src/shared/runtime-host-status-owner.test.ts b/src/shared/runtime-host-status-owner.test.ts index 32331991988..d9093b39762 100644 --- a/src/shared/runtime-host-status-owner.test.ts +++ b/src/shared/runtime-host-status-owner.test.ts @@ -205,3 +205,38 @@ it.each(['unknown', 'ready'] as const)( }) } ) + +it.each([ + ['unauthorized', 'access-revoked'], + ['forbidden', 'access-revoked'], + ['protocol_version_mismatch', 'incompatible'], + ['method_not_found', 'incompatible'] +] as const)('labels a %s refusal as %s until verification leaves blocked', async (code, reason) => { + const { owner, request } = createOwner() + request.mockResolvedValueOnce(runtimeHostStatusFailure(code, 'refused')) + await owner.refresh() + expect(owner.read()).toMatchObject({ + verification: 'blocked', + blockedReason: reason + }) + owner.connectionChanged('ready') + expect(owner.read().blockedReason).toBe(reason) + await owner.refresh({ reconnect: true }) + expect(owner.read().verification).toBe('verified') + expect(owner.read()).not.toHaveProperty('blockedReason') +}) + +it('does not label a transient failure or a retired owner with a blocked reason', async () => { + const { owner, request } = createOwner() + request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'offline')) + await owner.refresh() + expect(owner.read()).not.toHaveProperty('blockedReason') + owner.authenticationRejected() + expect(owner.read().blockedReason).toBe('access-revoked') + owner.dispose() + expect(owner.read()).toMatchObject({ + verification: 'blocked', + retired: true + }) + expect(owner.read()).not.toHaveProperty('blockedReason') +}) diff --git a/src/shared/runtime-host-status-owner.ts b/src/shared/runtime-host-status-owner.ts index d9c52395c89..cfb68e24226 100644 --- a/src/shared/runtime-host-status-owner.ts +++ b/src/shared/runtime-host-status-owner.ts @@ -1,5 +1,5 @@ import { - isRuntimeHostStatusBlocked, + runtimeHostStatusBlockedReason, runtimeHostStatusError, runtimeHostStatusFailure, type RuntimeHostStatusResponse, @@ -221,9 +221,11 @@ export class RuntimeHostStatusOwner { this.update({ status: response.result, checkedAt: Date.now(), verification: 'verified' }) this.persistent = this.options.verified(response, this.active) } else { + const blockedReason = runtimeHostStatusBlockedReason(response) this.update({ checkedAt: Date.now(), - verification: isRuntimeHostStatusBlocked(response) ? 'blocked' : 'unavailable' + verification: blockedReason ? 'blocked' : 'unavailable', + ...(blockedReason ? { blockedReason } : {}) }) this.scheduleRetry() } @@ -268,7 +270,14 @@ export class RuntimeHostStatusOwner { } private update(patch: Partial): void { - this.snapshot = { ...this.snapshot, ...patch, sequence: ++publicationSequence } + const { blockedReason, ...next } = { ...this.snapshot, ...patch } + // Why: a retired owner is a disconnect, not a refusal, so it never carries a reason. + const keepReason = blockedReason && next.verification === 'blocked' && !next.retired + this.snapshot = { + ...next, + ...(keepReason ? { blockedReason } : {}), + sequence: ++publicationSequence + } this.options.publish(this.snapshot) } } diff --git a/src/shared/runtime-host-status.ts b/src/shared/runtime-host-status.ts index d3df230e01b..db28469ff6b 100644 --- a/src/shared/runtime-host-status.ts +++ b/src/shared/runtime-host-status.ts @@ -4,6 +4,9 @@ import type { RuntimeStatus } from './runtime-types' export const RUNTIME_HOST_STATUS_CHANNEL = 'runtimeEnvironments:statusChanged' +/** Why a host refused this client: its pairing was revoked, or the versions cannot talk. */ +export type RuntimeHostBlockedReason = 'access-revoked' | 'incompatible' + /** Local client state; never exchanged with the paired host. */ export type RuntimeHostStatusSnapshot = { environmentId: string @@ -12,6 +15,8 @@ export type RuntimeHostStatusSnapshot = { checkedAt: number status: RuntimeStatus | null verification: 'checking' | 'verified' | 'unavailable' | 'blocked' + /** Set only while a refused status probe holds `verification` at blocked. */ + blockedReason?: RuntimeHostBlockedReason transport: 'unknown' | 'connecting' | 'ready' | 'disconnected' remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null retired?: true @@ -98,14 +103,22 @@ export function runtimeHostStatusError(error: unknown): RuntimeRpcFailure { return runtimeHostStatusFailure(code, error instanceof Error ? error.message : String(error)) } -export function isRuntimeHostStatusBlocked(response: RuntimeRpcFailure): boolean { +const ACCESS_REVOKED_CODES = ['unauthorized', 'forbidden'] + +/** Null when the failure is transient and verification should keep retrying. */ +export function runtimeHostStatusBlockedReason( + response: RuntimeRpcFailure +): RuntimeHostBlockedReason | null { + if (ACCESS_REVOKED_CODES.includes(response.error.code)) { + return 'access-revoked' + } return [ - 'unauthorized', - 'forbidden', 'invalid_argument', 'invalid_runtime_response', 'protocol_version_mismatch', 'method_not_found', 'unsupported_method' ].includes(response.error.code) + ? 'incompatible' + : null } diff --git a/src/shared/sidebar-list/host-section-rows.ts b/src/shared/sidebar-list/host-section-rows.ts index 3af238b806c..43b0b5cd1d5 100644 --- a/src/shared/sidebar-list/host-section-rows.ts +++ b/src/shared/sidebar-list/host-section-rows.ts @@ -10,6 +10,7 @@ import { } from '../execution-host' import type { ExecutionHostHealth } from '../execution-host-registry' import type { RuntimeCompatVerdict } from '../protocol-compat' +import type { RuntimeHostBlockedReason } from '../runtime-host-status' import type { SshConnectionStatus } from '../ssh-types' import type { Repo } from '../repo-types' import type { Row } from './row-types' @@ -26,6 +27,8 @@ export type HostHeaderRow = { // Why: blocked-host guidance in the header menu needs the verdict reason so // it can deep-link an "Update server/client required" row per skew direction. compatibility?: RuntimeCompatVerdict + // Why: a revoked pairing needs "Re-pair", not the update guidance a version block gets. + blockedReason?: RuntimeHostBlockedReason connectionStatus?: SshConnectionStatus collapsed: boolean count: number @@ -40,6 +43,7 @@ export type HostSectionOption = { detail: string health: ExecutionHostHealth compatibility?: RuntimeCompatVerdict + blockedReason?: RuntimeHostBlockedReason connectionStatus?: SshConnectionStatus } @@ -295,6 +299,7 @@ export function addHostSectionRows(args: { detail: host.detail, health: host.health, compatibility: host.compatibility, + ...(host.blockedReason ? { blockedReason: host.blockedReason } : {}), connectionStatus: host.connectionStatus, collapsed, count: countWorkspaceRows(hostRows)