fix(runtime): show "Re-pair required" for revoked servers and re-pair in place (#30271)

* fix(runtime): show Re-pair required for revoked servers and re-pair in place

A server that revokes this computer's pairing token was labeled "Update
required" because auth refusals and protocol mismatches shared one blocked
state. Carry the blocked reason locally, label revoked hosts "Re-pair
required", and add a Re-pair action (sidebar host menu + Settings server
row) that verifies a new pairing link reaches the same server before
replacing the saved pairing.

* fix(sidebar): stop host header menu clicks from toggling or dragging the row

* fix(sidebar): vertically center host header label and menu button
This commit is contained in:
Neil
2026-10-18 01:55:08 -07:00
committed by GitHub
parent acc235187f
commit c30753c739
42 changed files with 1100 additions and 170 deletions
@@ -2,6 +2,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:verifyAndAddFromPairingCode',
'runtimeEnvironments:repairFromPairingCode',
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
@@ -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<VerifyAndAddRuntimeEnvironmentResult> {
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<VerifiedPairingCode | RemotePairingFailure> {
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<RuntimeStatus>(
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<VerifyAndAddRuntimeEnvironmentResult> {
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<typeof addEnvironmentFromPairingCode>
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<VerifyAndAddRuntimeEnvironmentResult> {
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<typeof updateEnvironmentFromPairingCode>
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',
@@ -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
}): void {
ipcMain.handle(
'runtimeEnvironments:repairFromPairingCode',
async (
_event,
args: { selector: string; pairingCode: string }
): Promise<VerifyAndAddRuntimeEnvironmentResult> => {
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
}
)
}
@@ -58,12 +58,15 @@ export function withRuntimeStatusOwners<T extends Record<string, Mock>>(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')
})
}
@@ -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()
})
})
+5
View File
@@ -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 (
+5
View File
@@ -93,6 +93,11 @@ export type RuntimeApi = {
pairingCode: string
allowLoopback?: boolean
}) => Promise<VerifyAndAddRuntimeEnvironmentResult>
/** Replaces a revoked pairing in place; refuses a link for a different server. */
repairFromPairingCode: (args: {
selector: string
pairingCode: string
}) => Promise<VerifyAndAddRuntimeEnvironmentResult>
resolve: (args: { selector: string }) => Promise<PublicKnownRuntimeEnvironment>
remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }>
disconnect: (args: {
@@ -45,6 +45,11 @@ export const runtimeEnvironmentsApi = {
allowLoopback?: boolean
}): Promise<VerifyAndAddRuntimeEnvironmentResult> =>
ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args),
repairFromPairingCode: (args: {
selector: string
pairingCode: string
}): Promise<VerifyAndAddRuntimeEnvironmentResult> =>
ipcRenderer.invoke('runtimeEnvironments:repairFromPairingCode', args),
resolve: (args: { selector: string }): Promise<PublicKnownRuntimeEnvironment> =>
ipcRenderer.invoke('runtimeEnvironments:resolve', args),
remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> =>
@@ -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 (
<div id={id} role="alert" className="space-y-1 rounded-md border border-destructive/50 p-3">
<div className="text-sm font-medium text-destructive">{failureTitle(failure.kind)}</div>
<p className="text-xs text-muted-foreground">
{failure.kind === 'environment-save-failed'
? failure.message
: translateRemotePairingFailureDescription(failure.kind, endpoint)}
</p>
</div>
)
}
@@ -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 ? (
<div
<RuntimeHostAccessFailureAlert
id="runtime-server-verification-error"
role="alert"
className="space-y-1 rounded-md border border-destructive/50 p-3"
>
<div className="text-sm font-medium text-destructive">
{failure.kind === 'host-identity-mismatch'
? translate(
'auto.components.settings.RuntimeHostAccessForm.identityMismatch',
'The reached Orca host does not match this access link'
)
: failure.kind === 'access-link-invalid'
? translate(
'auto.components.settings.RuntimeHostAccessForm.invalidLink',
'This access link is no longer valid'
)
: failure.kind === 'protocol-incompatible'
? translate(
'auto.components.settings.RuntimeHostAccessForm.incompatible',
'Orca versions are not compatible'
)
: failure.kind === 'connection-interrupted'
? translate(
'auto.components.settings.RuntimeHostAccessForm.interrupted',
'Connection interrupted'
)
: failure.kind === 'environment-save-failed'
? translate(
'auto.components.settings.RuntimeHostAccessForm.saveFailed',
'Could not save the host'
)
: translate(
'auto.components.settings.RuntimeHostAccessForm.unavailable',
'Host unavailable'
)}
</div>
<p className="text-xs text-muted-foreground">
{failure.kind === 'environment-save-failed'
? failure.message
: translateRemotePairingFailureDescription(
failure.kind,
parsed.ok ? parsed.value.displayEndpoint : null
)}
</p>
</div>
failure={failure}
endpoint={parsed.ok ? parsed.value.displayEndpoint : null}
/>
) : null}
<details className="group text-xs">
@@ -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 (
<Dialog open={open} onOpenChange={(next) => !busy && onOpenChange(next)}>
<DialogContent className="sm:max-w-md">
{/* Why inside the content: it unmounts on close, so a reopen starts from an empty link. */}
<RuntimeRepairForm
environmentId={environmentId}
name={name}
busy={busy}
setBusy={setBusy}
onClose={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
)
}
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<RuntimeHostAccessFailure | null>(null)
const mountedRef = useMountedRef()
const parsed = useMemo(() => parseHostAccessLink(accessLink), [accessLink])
const inputError = accessLink.trim() !== '' && !parsed.ok
const submit = async (): Promise<void> => {
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 (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
void submit()
}}
>
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.RuntimeRepairDialog.title', 'Re-pair {{name}}', {
name
})}
</DialogTitle>
<DialogDescription>
{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 }
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="runtime-repair-access-link">
{translate('auto.components.settings.RuntimeHostAccessForm.accessLink', 'Access link')}
</Label>
<Input
id="runtime-repair-access-link"
autoFocus
value={accessLink}
disabled={busy}
aria-invalid={inputError || failure !== null}
aria-describedby={
failure
? 'runtime-repair-error'
: inputError
? 'runtime-repair-access-link-error'
: undefined
}
onChange={(event) => {
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 ? (
<p id="runtime-repair-access-link-error" className="text-xs text-destructive">
{translateHostAccessLinkError(parsed.kind)}
</p>
) : null}
</div>
{failure ? (
<RuntimeHostAccessFailureAlert
id="runtime-repair-error"
failure={failure}
endpoint={parsed.ok ? parsed.value.displayEndpoint : null}
/>
) : null}
<DialogFooter>
<Button type="button" variant="outline" disabled={busy} onClick={onClose}>
{translate('auto.components.settings.RuntimeHostAccessForm.cancel', 'Cancel')}
</Button>
<Button type="submit" disabled={!parsed.ok || busy}>
{busy ? <Loader2 className="animate-spin" /> : <Link2 />}
{translate('auto.components.settings.RuntimeRepairDialog.submit', 'Re-pair')}
</Button>
</DialogFooter>
</form>
)
}
@@ -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({
<span className="text-[11px] text-muted-foreground">
{getRuntimeServerConnectionLabel(connectionState)}
</span>
{effectiveDetails?.compatibility?.kind === 'blocked' ? (
{accessRevoked || effectiveDetails?.compatibility?.kind === 'blocked' ? (
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
) : effectiveDetails?.status === 'loading' ? (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
@@ -145,7 +164,7 @@ export function RuntimeServerRow({
<p
className={cn(
'mt-0.5 truncate text-xs',
effectiveDetails?.compatibility?.kind === 'blocked'
accessRevoked || effectiveDetails?.compatibility?.kind === 'blocked'
? 'text-destructive'
: 'text-muted-foreground'
)}
@@ -191,6 +210,18 @@ export function RuntimeServerRow({
{translate('auto.components.settings.RuntimeEnvironmentsPane.updateServer', 'Update')}
</Button>
) : null}
{accessRevoked && repairable ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={() => setRepairOpen(true)}
disabled={actionBusy}
>
<Link2 />
{translate('auto.components.settings.RuntimeEnvironmentsPane.repair', 'Re-pair…')}
</Button>
) : null}
{isReachable ? (
<Button
type="button"
@@ -240,6 +271,12 @@ export function RuntimeServerRow({
{removing ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
</div>
<RuntimeRepairDialog
open={repairOpen}
onOpenChange={setRepairOpen}
environmentId={environment.id}
name={environment.name}
/>
</div>
)
}
@@ -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 = {
@@ -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[]
@@ -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)}
</span>
) : null}
<ChevronsUpDown className="size-3.5 shrink-0 opacity-50" />
@@ -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
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-56">
{model.actions.includes('runtime-repair') && (
<>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => setRepairOpen(true)}
>
<Link2 className="size-3.5" />
{translate('auto.components.sidebar.HostSectionHeaderMenu.repair', 'Re-pair…')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{model.blocked && (
<>
<Tooltip>
@@ -280,6 +298,14 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
hostId={row.hostId}
derivedLabel={row.label}
/>
{runtimeEnvironmentId && (
<RuntimeRepairDialog
open={repairOpen}
onOpenChange={setRepairOpen}
environmentId={runtimeEnvironmentId}
name={row.label}
/>
)}
{removalTarget && (
<HostRemoveDialog
open={removeOpen}
@@ -29,7 +29,7 @@ type SidebarHostScopeMenuSectionProps = {
}
function getHostMetadata(host: SidebarHostOption): string {
const healthLabel = getSidebarHostHealthLabel(host.health)
const healthLabel = getSidebarHostHealthLabel(host.health, host.blockedReason)
if (host.kind === 'local') {
return host.detail
}
@@ -89,4 +89,44 @@ describe('buildHostHeaderMenuModel', () => {
})
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')
})
})
@@ -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
@@ -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'
})
})
@@ -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')
})
@@ -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)
}
@@ -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({
)}
<HostHeaderHealthIcon health={row.health} />
{/* Why: badge hugs the label (like repo headers) instead of floating by the hover controls. */}
<div className="flex min-w-0 flex-1 items-baseline gap-1.5">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<span
className={cn(
'min-w-0 truncate text-[12px] font-semibold leading-none',
@@ -152,7 +122,17 @@ export function HostSectionHeader({
className={cn('size-3.5 transition-transform', row.collapsed && '-rotate-90')}
/>
</div>
<span data-host-header-action="">
{/* Why: menu and dialog portals still bubble React events here; keep them from toggling or dragging the row. */}
<span
data-host-header-action=""
className="flex shrink-0 items-center"
onPointerDown={stopRepoHeaderMenuEvent}
onMouseDown={stopRepoHeaderMenuEvent}
onPointerUp={stopRepoHeaderMenuEvent}
onMouseUp={stopRepoHeaderMenuEvent}
onClick={stopRepoHeaderMenuEvent}
onKeyDown={stopRepoHeaderMenuEvent}
>
<HostSectionHeaderMenu row={row} />
</span>
</div>
@@ -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
})
})
})
@@ -0,0 +1,45 @@
import { translate } from '@/i18n/i18n'
import type { HostHeaderRow } from '../../../../../../shared/sidebar-list/host-section-rows'
export function getHostHeaderDetail(
row: Pick<HostHeaderRow, 'health' | 'blockedReason' | 'connectionStatus' | 'kind' | 'detail'>
): { 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
}
+19 -5
View File
@@ -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": {
+19 -5
View File
@@ -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": {
+19 -5
View File
@@ -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": {
+19 -5
View File
@@ -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": {
+19 -5
View File
@@ -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": {
+19 -5
View File
@@ -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": {
@@ -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',
@@ -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 }) => {
+6 -2
View File
@@ -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':
@@ -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>): 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')
})
})
+10 -2
View File
@@ -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,
@@ -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
+2 -2
View File
@@ -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)
})
@@ -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')
})
+12 -3
View File
@@ -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<RuntimeHostStatusSnapshot>): 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)
}
}
+16 -3
View File
@@ -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
}
@@ -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)