fix: make remote server pairing failures actionable (#11510)

* fix: make remote server pairing failures actionable

* refactor: extract daemon router event types

* fix: address remote pairing review findings

* fix: address final remote pairing review feedback
This commit is contained in:
Jinjing
2026-07-30 00:31:34 -07:00
committed by GitHub
parent 191fdf2ae6
commit a60aa85592
48 changed files with 3389 additions and 522 deletions
@@ -0,0 +1,15 @@
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type DaemonPtyRouterDataEvent = {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}
export type DaemonPtyRouterExitEvent = {
id: string
code: number
incarnationId?: PtyIncarnationId
}
+5 -25
View File
@@ -8,28 +8,18 @@ import type {
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import { probePtyOwners } from './daemon-pty-liveness-probe'
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
export class DaemonPtyRouter implements IPtyProvider {
private current: DaemonPtyAdapter
private legacy: DaemonPtyAdapter[]
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
private unsubscribers: (() => void)[] = []
private dataListeners: ((payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void)[] = []
private exitListeners: ((payload: {
id: string
code: number
incarnationId?: PtyIncarnationId
}) => void)[] = []
private dataListeners: ((payload: DaemonPtyRouterDataEvent) => void)[] = []
private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = []
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
this.current = opts.current
@@ -231,15 +221,7 @@ export class DaemonPtyRouter implements IPtyProvider {
return this.current.getProfiles()
}
onData(
callback: (payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void
): () => void {
onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void {
this.dataListeners.push(callback)
return () => {
const idx = this.dataListeners.indexOf(callback)
@@ -267,9 +249,7 @@ export class DaemonPtyRouter implements IPtyProvider {
return () => {}
}
onExit(
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
): () => void {
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
@@ -12,6 +12,7 @@ import {
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { Store } from '../persistence'
import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification'
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
import {
callRuntimeEnvironment,
@@ -64,6 +65,16 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
return { environment: redactRuntimeEnvironment(environment) }
}
)
ipcMain.handle(
'runtimeEnvironments:verifyAndAddFromPairingCode',
async (_event, args: { name: string; pairingCode: string; allowLoopback?: boolean }) => {
const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args)
if (result.ok) {
manuallyDisconnectedEnvironmentIds.delete(result.environment.id)
}
return result
}
)
ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) =>
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
)
@@ -0,0 +1,13 @@
export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:verifyAndAddFromPairingCode',
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
'runtimeEnvironments:connect',
'runtimeEnvironments:getStatus',
'runtimeEnvironments:call',
'runtimeEnvironments:subscribe',
'runtimeEnvironments:unsubscribe'
] as const
@@ -0,0 +1,140 @@
import {
addEnvironmentFromPairingCode,
RuntimeEnvironmentStoreError
} from '../../shared/runtime-environment-store'
import { parseHostAccessLink } from '../../shared/remote-pairing-address'
import {
verifyRemotePairingRuntimeStatus,
type VerifyAndAddRuntimeEnvironmentResult
} from '../../shared/remote-pairing-verification'
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
import type { RuntimeStatus } from '../../shared/runtime-types'
type VerifyAndAddRuntimeEnvironmentArgs = {
name: string
pairingCode: string
allowLoopback?: boolean
}
export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
userDataPath: string,
args: VerifyAndAddRuntimeEnvironmentArgs
): Promise<VerifyAndAddRuntimeEnvironmentResult> {
const parsed = parseHostAccessLink(args.pairingCode)
if (!parsed.ok) {
return { ok: false, kind: 'access-link-invalid', message: parsed.message }
}
if (parsed.value.endpointKind === 'loopback' && !args.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>(
parsed.value.pairing,
'status.get',
undefined,
15_000
)
if (!response.ok) {
return {
ok: false,
kind: 'connection-interrupted',
message: response.error.message
}
}
const statusVerification = verifyRemotePairingRuntimeStatus(response.result)
if (!statusVerification.ok) {
return statusVerification
}
runtimeStatus = statusVerification.runtimeStatus
} catch (error) {
return classifyPairingVerificationError(error, parsed.value.displayEndpoint)
}
const usesSshTunnel = parsed.value.endpointKind === 'loopback' && args.allowLoopback === true
let environment: ReturnType<typeof addEnvironmentFromPairingCode>
try {
environment = addEnvironmentFromPairingCode(userDataPath, {
...args,
...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {})
})
} catch (error) {
return {
ok: false,
kind: 'environment-save-failed',
message:
error instanceof RuntimeEnvironmentStoreError && error.code === 'invalid_argument'
? error.message
: 'Orca verified the host but could not save it. Check local settings storage and try again.'
}
}
return {
ok: true,
environment: redactRuntimeEnvironment(environment),
runtimeStatus
}
}
function classifyPairingVerificationError(
error: unknown,
endpoint: string
): VerifyAndAddRuntimeEnvironmentResult {
if (error instanceof RemoteRuntimeClientError) {
if (error.code === 'invalid_argument') {
return invalidAccessLinkResult()
}
if (error.code === 'unauthorized' || error.pairingStage === 'access-grant') {
return {
ok: false,
kind: 'access-link-invalid',
message: 'This access link is no longer valid. Generate a new link on the other host.'
}
}
if (error.pairingStage === 'host-identity') {
return {
ok: false,
kind: 'host-identity-mismatch',
message: `Orca reached ${endpoint}, but that host does not match this access link.`
}
}
if (error.pairingStage === 'runtime') {
return {
ok: false,
kind: 'connection-interrupted',
message: `The connection to ${endpoint} was interrupted during verification.`
}
}
if (error.pairingStage === 'connect') {
return unreachableHostResult(endpoint)
}
}
if (error instanceof Error) {
return error.message.startsWith('Invalid public key')
? invalidAccessLinkResult()
: { ok: false, kind: 'connection-interrupted', message: error.message }
}
return unreachableHostResult(endpoint)
}
function invalidAccessLinkResult(): VerifyAndAddRuntimeEnvironmentResult {
return {
ok: false,
kind: 'access-link-invalid',
message: 'This access link contains invalid connection details.'
}
}
function unreachableHostResult(endpoint: string): VerifyAndAddRuntimeEnvironmentResult {
return {
ok: false,
kind: 'host-unreachable',
message: `Cannot reach Orca at ${endpoint}. Confirm the other host is running and reachable.`
}
}
+186 -1
View File
@@ -5,8 +5,12 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer } from '../../shared/pairing'
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version'
import {
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY
} from '../../shared/protocol-version'
import * as environmentStore from '../../shared/runtime-environment-store'
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
const {
handleMock,
@@ -79,6 +83,18 @@ function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
})
}
function runtimeStatus(): Record<string, unknown> {
return {
runtimeId: 'runtime-a',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
protocolVersion: 999_999
}
}
function handler<TArgs, TResult>(
channel: string
): (_event: unknown, args: TArgs) => TResult | Promise<TResult> {
@@ -132,6 +148,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(handleMock.mock.calls.map((call) => call[0])).toEqual([
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:verifyAndAddFromPairingCode',
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
@@ -153,6 +170,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:verifyAndAddFromPairingCode',
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
@@ -211,6 +229,173 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(await list(null, undefined)).toEqual([])
})
it('blocks loopback before verification unless an SSH tunnel is declared', async () => {
registerRuntimeEnvironmentHandlers(store as never)
const verifyAndAdd = handler<
{ name: string; pairingCode: string; allowLoopback?: boolean },
{ ok: boolean; kind?: string }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
await expect(
verifyAndAdd(null, { name: 'desk', pairingCode: pairingCode() })
).resolves.toMatchObject({
ok: false,
kind: 'host-unreachable'
})
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
})
it('verifies identity, access, status, and compatibility before saving', async () => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
result: runtimeStatus(),
_meta: { runtimeId: 'runtime-a' }
})
const verifyAndAdd = handler<
{ name: string; pairingCode: string; allowLoopback?: boolean },
{ ok: boolean; environment?: { name: string; connectionDependency?: string } }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
const result = await verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode(),
allowLoopback: true
})
expect(result).toMatchObject({
ok: true,
environment: { name: 'desk', connectionDependency: 'ssh-tunnel' }
})
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith(
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
'status.get',
undefined,
15_000
)
expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1)
})
it('does not mark non-loopback hosts as SSH-tunnel dependent', async () => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
result: runtimeStatus(),
_meta: { runtimeId: 'runtime-a' }
})
const verifyAndAdd = handler<
{ name: string; pairingCode: string; allowLoopback?: boolean },
{ ok: boolean; environment?: { connectionDependency?: string } }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
const result = await verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768'),
allowLoopback: true
})
expect(result).toMatchObject({ ok: true })
expect(result.environment).not.toHaveProperty('connectionDependency')
})
it.each([
[{ protocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 }, 'protocol-incompatible'],
[{ protocolVersion: 999_999, deviceScope: 'mobile' }, 'access-link-invalid'],
[null, 'connection-interrupted']
])('does not save a host with rejected status %o', async (status, expectedKind) => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
result: status,
_meta: { runtimeId: 'runtime-a' }
})
const verifyAndAdd = handler<
{ name: string; pairingCode: string },
{ ok: boolean; kind?: string }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
await expect(
verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768')
})
).resolves.toMatchObject({ ok: false, kind: expectedKind })
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
})
it.each([
[
new RemoteRuntimeClientError('remote_runtime_unavailable', 'closed', {
pairingStage: 'host-identity',
closeCode: 4001
}),
'host-identity-mismatch'
],
[
new RemoteRuntimeClientError('unauthorized', 'rejected', {
pairingStage: 'access-grant'
}),
'access-link-invalid'
],
[
new RemoteRuntimeClientError('remote_runtime_unavailable', 'offline', {
pairingStage: 'connect'
}),
'host-unreachable'
],
[new Error('Invalid public key: expected 32 bytes, got 3'), 'access-link-invalid']
] as const)('returns a structured pairing failure for %s', async (error, expectedKind) => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockRejectedValue(error)
const verifyAndAdd = handler<
{ name: string; pairingCode: string },
{ ok: boolean; kind?: string; message?: string }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
const result = await verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768')
})
expect(result).toMatchObject({ ok: false, kind: expectedKind })
expect(result.message).not.toContain('4001')
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
})
it('returns a structured failure when a verified host cannot be persisted', async () => {
registerRuntimeEnvironmentHandlers(store as never)
environmentStore.addEnvironmentFromPairingCode(userDataPath, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768')
})
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
result: runtimeStatus(),
_meta: { runtimeId: 'runtime-a' }
})
const verifyAndAdd = handler<
{ name: string; pairingCode: string },
{ ok: boolean; kind?: string; message?: string }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
await expect(
verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768')
})
).resolves.toMatchObject({
ok: false,
kind: 'environment-save-failed',
message: 'A server named "desk" already exists.'
})
expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1)
})
it('requires an explicit Advanced selection before removing the Active Server', async () => {
registerRuntimeEnvironmentHandlers(store as never)
const add = handler<
+1 -13
View File
@@ -19,19 +19,7 @@ import {
resetSharedControlSupport,
subscribeRuntimeEnvironment
} from './runtime-environment-transport-routing'
const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
'runtimeEnvironments:connect',
'runtimeEnvironments:getStatus',
'runtimeEnvironments:call',
'runtimeEnvironments:subscribe',
'runtimeEnvironments:unsubscribe'
] as const
import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels'
type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & {
environmentId: string
+6
View File
@@ -47,6 +47,7 @@ import type {
} from '../shared/terminal-render-desync-evidence'
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification'
import type {
SshMutationExpectation,
SshConnectionState,
@@ -3314,6 +3315,11 @@ export type PreloadApi = {
name: string
pairingCode: string
}) => Promise<{ environment: PublicKnownRuntimeEnvironment }>
verifyAndAddFromPairingCode: (args: {
name: string
pairingCode: string
allowLoopback?: boolean
}) => Promise<VerifyAndAddRuntimeEnvironmentResult>
resolve: (args: { selector: string }) => Promise<PublicKnownRuntimeEnvironment>
remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }>
disconnect: (args: {
+7
View File
@@ -22,6 +22,7 @@ import type {
} from '../shared/agent-session-resume'
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification'
import type {
SshMutationExpectation,
SshConnectionState,
@@ -4273,6 +4274,12 @@ const api = {
pairingCode: string
}): Promise<{ environment: PublicKnownRuntimeEnvironment }> =>
ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args),
verifyAndAddFromPairingCode: (args: {
name: string
pairingCode: string
allowLoopback?: boolean
}): Promise<VerifyAndAddRuntimeEnvironmentResult> =>
ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args),
resolve: (args: { selector: string }): Promise<PublicKnownRuntimeEnvironment> =>
ipcRenderer.invoke('runtimeEnvironments:resolve', args),
remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> =>
@@ -113,7 +113,7 @@ describe('EphemeralVmRuntimesSection', () => {
document.body.replaceChildren()
})
it('renders active temporary VM runtimes and cleans one up', async () => {
it('renders active Cloud VM runtimes and cleans one up', async () => {
const container = await renderSection()
await vi.waitFor(() => expect(container.textContent).toContain('Fix Login Race'))
@@ -139,7 +139,7 @@ describe('EphemeralVmRuntimesSection', () => {
const container = await renderSection()
await vi.waitFor(() =>
expect(container.textContent).toContain('No temporary VM runtimes need cleanup.')
expect(container.textContent).toContain('No Cloud VM runtimes need cleanup.')
)
})
@@ -68,8 +68,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
error instanceof Error
? error.message
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.loadFailed',
'Couldnt load temporary VM runtimes.'
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoadFailed',
'Couldnt load Cloud VM runtimes.'
)
)
}
@@ -92,8 +92,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
throw new Error(
cleaned.cleanupLastError ??
translate(
'auto.components.settings.EphemeralVmRuntimesSection.cleanupFailedToast',
'Couldnt clean up temporary VM runtime.'
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast',
'Couldnt clean up Cloud VM runtime.'
)
)
}
@@ -101,12 +101,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
toast.success(
cleaned.cleanupStatus === 'disabled'
? translate(
'auto.components.settings.EphemeralVmRuntimesSection.markedCleaned',
'Marked temporary VM runtime as cleaned.'
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmMarkedCleaned',
'Marked Cloud VM runtime as cleaned.'
)
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.cleaned',
'Cleaned up temporary VM runtime.'
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleaned',
'Cleaned up Cloud VM runtime.'
)
)
}
@@ -117,8 +117,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
error instanceof Error
? error.message
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.cleanupFailedToast',
'Couldnt clean up temporary VM runtime.'
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast',
'Couldnt clean up Cloud VM runtime.'
)
)
await refresh()
@@ -171,8 +171,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
<div className="min-w-0 space-y-0.5">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.EphemeralVmRuntimesSection.title',
'Temporary VM runtimes'
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmTitle',
'Cloud VM runtimes'
)}
</div>
<p className="text-xs text-muted-foreground">
@@ -187,12 +187,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
variant="outline"
size="icon-sm"
aria-label={translate(
'auto.components.settings.EphemeralVmRuntimesSection.refresh',
'Refresh temporary VM runtimes'
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh',
'Refresh Cloud VM runtimes'
)}
title={translate(
'auto.components.settings.EphemeralVmRuntimesSection.refresh',
'Refresh temporary VM runtimes'
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh',
'Refresh Cloud VM runtimes'
)}
onClick={() => void refresh()}
disabled={isLoading || cleaningId !== null}
@@ -206,12 +206,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
<div className="px-3 py-4 text-sm text-muted-foreground">
{isLoading
? translate(
'auto.components.settings.EphemeralVmRuntimesSection.loading',
'Checking temporary VM runtimes…'
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoading',
'Checking Cloud VM runtimes…'
)
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.empty',
'No temporary VM runtimes need cleanup.'
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmEmpty',
'No Cloud VM runtimes need cleanup.'
)}
</div>
) : (
@@ -34,7 +34,6 @@ import {
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { SearchableSetting } from './SearchableSetting'
@@ -61,6 +60,7 @@ import {
getRemoteServerManualUpdateHelp,
RemoteServerUpdateStatus
} from './RemoteServerUpdateStatus'
import { RuntimeHostAccessForm, type RuntimeHostAccessFailure } from './RuntimeHostAccessForm'
const LOCAL_RUNTIME_VALUE = '__local__'
const NO_RUNTIME_VALUE = '__none__'
@@ -208,6 +208,7 @@ export function isRuntimeEnvironmentRemovalBlocked(
}
type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected'
type RemoteServerWorkflow = 'connect' | 'cloud-vm' | 'share'
export function getRuntimeServerConnectionState(
details: RuntimeHostDetails | undefined
@@ -276,12 +277,14 @@ export function RuntimeEnvironmentsPane({
const [pendingSwitchValue, setPendingSwitchValue] = useState<string | null>(null)
const [pendingRemove, setPendingRemove] = useState<PublicKnownRuntimeEnvironment | null>(null)
const [addServerFormOpen, setAddServerFormOpen] = useState(false)
const [shareServerFormOpen, setShareServerFormOpen] = useState(false)
const [shareServerFormOpen, setShareServerFormOpen] = useState(true)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [workflow, setWorkflow] = useState<RemoteServerWorkflow>('connect')
const [switchError, setSwitchError] = useState<string | null>(null)
const [removeError, setRemoveError] = useState<string | null>(null)
const [name, setName] = useState('')
const [pairingCode, setPairingCode] = useState('')
const [addServerFailure, setAddServerFailure] = useState<RuntimeHostAccessFailure | null>(null)
const remoteServerUpdates = useAppStore((state) => state.remoteServerUpdates)
const remoteServerUpdatesChecking = useAppStore((state) => state.remoteServerUpdatesChecking)
const remoteServerUpdatesRunning = useAppStore((state) => state.remoteServerUpdatesRunning)
@@ -308,96 +311,115 @@ export function RuntimeEnvironmentsPane({
? getRuntimeEnvironmentsSearchEntry()
: getWebRuntimeEnvironmentsSearchEntry()
const loadEnvironments = useCallback(async (): Promise<void> => {
if (mountedRef.current) {
setIsLoading(true)
}
try {
const nextEnvironments = await window.api.runtimeEnvironments.list()
const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment)
// Why: drop store status for servers no longer saved so stale hosts don't
// linger in the sidebar registry.
useAppStore.getState().setRuntimeEnvironments(nextEnvironments)
const loadEnvironments = useCallback(
async (verified?: { environmentId: string; runtimeStatus: RuntimeStatus }): Promise<void> => {
if (mountedRef.current) {
setEnvironments(visibleEnvironments)
setDetailsByEnvironmentId((current) => {
const next: Record<string, RuntimeHostDetails> = {}
for (const environment of visibleEnvironments) {
next[environment.id] = current[environment.id] ?? {
status: 'loading',
runtimeStatus: null,
compatibility: null,
error: null
}
}
return next
})
setIsLoading(true)
}
await Promise.allSettled(
visibleEnvironments.map(async (environment) => {
try {
const response = await window.api.runtimeEnvironments.getStatus({
selector: environment.id,
timeoutMs: 10_000
})
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
// Why: feed the live status into the store so sidebar host pickers
// reflect manual refreshes, not just the settings pane.
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: runtimeStatus,
checkedAt: Date.now()
})
if (!mountedRef.current) {
return
try {
const nextEnvironments = await window.api.runtimeEnvironments.list()
const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment)
// Why: drop store status for servers no longer saved so stale hosts don't
// linger in the sidebar registry.
useAppStore.getState().setRuntimeEnvironments(nextEnvironments)
if (verified) {
useAppStore.getState().setRuntimeEnvironmentStatus(verified.environmentId, {
status: verified.runtimeStatus,
checkedAt: Date.now()
})
}
if (mountedRef.current) {
setEnvironments(visibleEnvironments)
setDetailsByEnvironmentId((current) => {
const next: Record<string, RuntimeHostDetails> = {}
for (const environment of visibleEnvironments) {
next[environment.id] =
verified?.environmentId === environment.id
? {
status: 'ready',
runtimeStatus: verified.runtimeStatus,
compatibility: evaluateHostDetails(verified.runtimeStatus),
error: null
}
: (current[environment.id] ?? {
status: 'loading',
runtimeStatus: null,
compatibility: null,
error: null
})
}
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: {
status: 'ready',
runtimeStatus,
compatibility: evaluateHostDetails(runtimeStatus),
error: null
return next
})
}
await Promise.allSettled(
visibleEnvironments
.filter((environment) => environment.id !== verified?.environmentId)
.map(async (environment) => {
try {
const response = await window.api.runtimeEnvironments.getStatus({
selector: environment.id,
timeoutMs: 10_000
})
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
// Why: feed the live status into the store so sidebar host pickers
// reflect manual refreshes, not just the settings pane.
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: runtimeStatus,
checkedAt: Date.now()
})
if (!mountedRef.current) {
return
}
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: {
status: 'ready',
runtimeStatus,
compatibility: evaluateHostDetails(runtimeStatus),
error: null
}
}))
} catch (error) {
// Why: record the failed probe (null status) so the sidebar can
// distinguish unreachable from never-checked.
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: null,
checkedAt: Date.now()
})
if (!mountedRef.current) {
return
}
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: {
status: 'error',
runtimeStatus: null,
compatibility: null,
error: error instanceof Error ? error.message : String(error)
}
}))
}
}))
} catch (error) {
// Why: record the failed probe (null status) so the sidebar can
// distinguish unreachable from never-checked.
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: null,
checkedAt: Date.now()
})
if (!mountedRef.current) {
return
}
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: {
status: 'error',
runtimeStatus: null,
compatibility: null,
error: error instanceof Error ? error.message : String(error)
}
}))
}
})
)
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3',
'Failed to load runtime environments.'
)
)
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3',
'Failed to load runtime environments.'
)
)
}
} finally {
if (mountedRef.current) {
setIsLoading(false)
}
}
} finally {
if (mountedRef.current) {
setIsLoading(false)
}
}
}, [mountedRef])
},
[mountedRef]
)
useEffect(() => {
void loadEnvironments()
@@ -427,9 +449,10 @@ export function RuntimeEnvironmentsPane({
setAddServerFormOpen(false)
setName('')
setPairingCode('')
setAddServerFailure(null)
}
const addEnvironment = async (): Promise<void> => {
const addEnvironment = async (allowLoopback: boolean): Promise<void> => {
const trimmedName = name.trim()
const trimmedPairingCode = pairingCode.trim()
if (!trimmedName || !trimmedPairingCode) {
@@ -454,17 +477,28 @@ export function RuntimeEnvironmentsPane({
)
return
}
setAddServerFailure(null)
setIsSaving(true)
try {
const result = await window.api.runtimeEnvironments.addFromPairingCode({
const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name: trimmedName,
pairingCode: trimmedPairingCode
pairingCode: trimmedPairingCode,
allowLoopback
})
if (!result.ok) {
if (mountedRef.current) {
setAddServerFailure({ kind: result.kind, message: result.message })
}
return
}
if (mountedRef.current) {
setName('')
setPairingCode('')
}
await loadEnvironments()
await loadEnvironments({
environmentId: result.environment.id,
runtimeStatus: result.runtimeStatus
})
if (!allowLocalRuntime) {
const connected = await connectEnvironment(result.environment)
if (!connected) {
@@ -477,7 +511,7 @@ export function RuntimeEnvironmentsPane({
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df',
'Saved {{value0}}. Use Advanced > Active Server to make it the default.',
'Connected to {{value0}}. Use Advanced > Active Server to make it the default.',
{ value0: result.environment.name }
)
)
@@ -723,6 +757,7 @@ export function RuntimeEnvironmentsPane({
}
return environments.find((environment) => environment.id === value)?.name ?? 'remote server'
}
const visibleWorkflow: RemoteServerWorkflow = addServerFormOpen ? 'connect' : workflow
return (
<SearchableSetting
@@ -731,7 +766,84 @@ export function RuntimeEnvironmentsPane({
keywords={searchEntry.keywords}
className="space-y-4 py-2"
>
<div className="space-y-3">
<div
role="group"
aria-label={translate(
'auto.components.settings.RuntimeEnvironmentsPane.workflow',
'Remote server workflow'
)}
className={cn('grid gap-2 sm:grid-cols-2', canGeneratePairingUrl && 'sm:grid-cols-3')}
>
{(
[
[
'connect',
translate(
'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflow',
'Connect to a host'
),
translate(
'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflowHelp',
'This app joins another machine'
)
],
[
'share',
translate(
'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflow',
'Share this host'
),
translate(
'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflowHelp',
'Other devices join this machine'
)
],
[
'cloud-vm',
translate(
'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflow',
'Cloud VM'
),
translate(
'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflowHelp',
'Manage recipe-created cloud machines'
)
]
] as const
)
.filter(([value]) => value !== 'share' || canGeneratePairingUrl)
.map(([value, label, description]) => (
<button
key={value}
type="button"
aria-pressed={visibleWorkflow === value}
onClick={() => {
if (value !== 'connect') {
closeAddServerForm()
}
setWorkflow(value)
}}
className={cn(
'rounded-lg border p-3 text-left transition-colors',
visibleWorkflow === value
? 'border-ring bg-accent text-accent-foreground'
: 'border-border hover:bg-accent'
)}
>
<span className="block text-sm font-medium">{label}</span>
<span
className={cn(
'mt-1 block text-xs',
visibleWorkflow === value ? 'text-accent-foreground' : 'text-muted-foreground'
)}
>
{description}
</span>
</button>
))}
</div>
<div className={cn('space-y-3', visibleWorkflow !== 'connect' && 'hidden')}>
<div
data-settings-section="remote-server-updates"
className="flex items-center justify-between gap-3"
@@ -800,89 +912,19 @@ export function RuntimeEnvironmentsPane({
</div>
{addServerFormOpen ? (
<form
className="space-y-3 rounded-lg border border-border/50 bg-muted/20 p-3"
onSubmit={(event) => {
event.preventDefault()
void addEnvironment()
<RuntimeHostAccessForm
name={name}
accessLink={pairingCode}
busy={isBusy}
failure={addServerFailure}
onNameChange={setName}
onAccessLinkChange={(value) => {
setPairingCode(value)
setAddServerFailure(null)
}}
>
<div className="grid gap-3 sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)]">
<div className="space-y-1">
<Label htmlFor="runtime-server-name">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.54ebacc600',
'Server name'
)}
</Label>
<Input
id="runtime-server-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={translate(
'auto.components.settings.RuntimeEnvironmentsPane.e038625857',
'Dev box'
)}
className="h-8 text-xs"
autoFocus
/>
</div>
<div className="space-y-1">
<Label htmlFor="runtime-server-pairing-code">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.9bc9b83474',
'Pairing code'
)}
</Label>
<Input
id="runtime-server-pairing-code"
aria-describedby="runtime-server-pairing-code-help"
value={pairingCode}
onChange={(event) => setPairingCode(event.target.value)}
placeholder={translate(
'auto.components.settings.RuntimeEnvironmentsPane.c3d772c514',
'orca://pair?code=...'
)}
className="h-8 min-w-0 font-mono text-xs"
/>
<p id="runtime-server-pairing-code-help" className="text-xs text-muted-foreground">
{translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')}{' '}
<span className="font-mono">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.960e901ae4',
'orca serve --pairing-address <host>'
)}
</span>{' '}
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.55fcc964cd',
'on the server and paste the printed pairing URL.'
)}
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={closeAddServerForm}
disabled={isSaving}
>
{translate('auto.components.settings.RuntimeEnvironmentsPane.af53761f31', 'Cancel')}
</Button>
<Button
type="submit"
size="sm"
disabled={isBusy || !name.trim() || !pairingCode.trim()}
>
{isSaving ? <Loader2 className="animate-spin" /> : <Plus />}
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.9bee6bbeeb',
'Add Server'
)}
</Button>
</div>
</form>
onCancel={closeAddServerForm}
onSubmit={(allowLoopback) => void addEnvironment(allowLoopback)}
/>
) : null}
<div className="rounded-lg border border-border/50 bg-card/30">
@@ -936,12 +978,17 @@ export function RuntimeEnvironmentsPane({
) : null}
</div>
<p className="truncate text-xs text-muted-foreground">
{isActive
{environment.connectionDependency === 'ssh-tunnel'
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp',
'Active server for server-routed projects, terminals, and provider checks.'
'auto.components.settings.RuntimeEnvironmentsPane.sshTunnelRequired',
'SSH tunnel required'
)
: getHostDetailsSummary(details)}
: isActive
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp',
'Active server for server-routed projects, terminals, and provider checks.'
)
: getHostDetailsSummary(details)}
</p>
{detailsDescription ? (
<p
@@ -1069,15 +1116,22 @@ export function RuntimeEnvironmentsPane({
</div>
</div>
<EphemeralVmRuntimesSection />
<div className={visibleWorkflow !== 'cloud-vm' ? 'hidden' : undefined}>
<EphemeralVmRuntimesSection />
</div>
<div data-settings-section="default-runtime">
<div
data-settings-section="default-runtime"
className={visibleWorkflow !== 'connect' ? 'hidden' : undefined}
>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setAdvancedOpen((current) => !current)}
className="-ml-2 text-xs"
aria-expanded={advancedOpen}
aria-controls="runtime-server-advanced-content"
>
{translate('auto.components.settings.RuntimeEnvironmentsPane.advanced', 'Advanced')}
<ChevronDown
@@ -1086,11 +1140,13 @@ export function RuntimeEnvironmentsPane({
</Button>
<div
id="runtime-server-advanced-content"
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-hidden={!advancedOpen}
inert={!advancedOpen}
>
<div className="min-h-0">
<div
@@ -1234,7 +1290,7 @@ export function RuntimeEnvironmentsPane({
</div>
</div>
{canGeneratePairingUrl ? (
{visibleWorkflow === 'share' && canGeneratePairingUrl ? (
<div className="space-y-3 pt-2">
<div className="space-y-0.5">
<div className="text-sm font-medium">
@@ -1296,6 +1352,60 @@ export function RuntimeEnvironmentsPane({
</div>
) : null}
{visibleWorkflow === 'connect' ? (
<details className="group rounded-lg border border-border/60">
<summary className="flex cursor-pointer list-none items-center gap-2 p-4 text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootWorkflow',
'Connection troubleshooting'
)}
<ChevronDown className="ml-auto size-4 transition-transform group-open:rotate-180" />
</summary>
<div className="space-y-4 border-t border-border/50 p-4">
<div className="space-y-1">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTitle',
'Create a new link on the other host'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootDescription',
'A link that uses 127.0.0.1 points back to the device opening it, not the computer that created it.'
)}
</p>
</div>
<ol className="ml-4 list-decimal space-y-1 text-xs text-muted-foreground">
<li>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepShare',
'On the other computer, open Share this host.'
)}
</li>
<li>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepAddress',
'Choose Another device and select its Tailscale or LAN address.'
)}
</li>
<li>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepRegenerate',
'Generate a new access link and use only the newest link here.'
)}
</li>
</ol>
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-xs">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.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.'
)}
</div>
</div>
</details>
) : null}
<Dialog
open={pendingSwitchValue !== null}
onOpenChange={(open) => {
@@ -0,0 +1,84 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../../shared/pairing'
import { RuntimeHostAccessForm } from './RuntimeHostAccessForm'
function accessLink(endpoint: string): string {
return encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint,
deviceToken: 'secret-device-token',
publicKeyB64: 'secret-public-key',
scope: 'runtime'
})
}
function renderForm(endpoint: string): string {
return renderToStaticMarkup(
<RuntimeHostAccessForm
name="Linux workstation"
accessLink={accessLink(endpoint)}
busy={false}
failure={null}
onNameChange={vi.fn()}
onAccessLinkChange={vi.fn()}
onCancel={vi.fn()}
onSubmit={vi.fn()}
/>
)
}
describe('RuntimeHostAccessForm', () => {
it('shows the sanitized destination without credential material', () => {
const markup = renderForm('ws://100.76.32.125:6768')
expect(markup).toContain('100.76.32.125:6768')
expect(markup).toContain('Tailscale address')
expect(markup).not.toContain('secret-device-token')
expect(markup).not.toContain('secret-public-key')
})
it('blocks accidental loopback and explains the recovery path', () => {
const markup = renderForm('ws://127.0.0.1:6768')
expect(markup).toContain('This link points back to this device')
expect(markup).toContain('I am using an SSH tunnel')
expect(markup).toContain('disabled')
})
it('shows actionable validation for malformed access links', () => {
const markup = renderToStaticMarkup(
<RuntimeHostAccessForm
name="Linux workstation"
accessLink="not-a-link"
busy={false}
failure={null}
onNameChange={vi.fn()}
onAccessLinkChange={vi.fn()}
onCancel={vi.fn()}
onSubmit={vi.fn()}
/>
)
expect(markup).toContain('Enter an Orca access link or bare pairing code.')
expect(markup).toContain('aria-invalid="true"')
})
it('shows the persistence error after host verification succeeds', () => {
const markup = renderToStaticMarkup(
<RuntimeHostAccessForm
name="Linux workstation"
accessLink={accessLink('ws://100.76.32.125:6768')}
busy={false}
failure={{
kind: 'environment-save-failed',
message: 'A server named "Linux workstation" already exists.'
}}
onNameChange={vi.fn()}
onAccessLinkChange={vi.fn()}
onCancel={vi.fn()}
onSubmit={vi.fn()}
/>
)
expect(markup).toContain('Could not save the host')
expect(markup).toContain('A server named &quot;Linux workstation&quot; already exists.')
})
})
@@ -0,0 +1,341 @@
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'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { translate } from '@/i18n/i18n'
import {
translateHostAccessLinkError,
translateRemotePairingEndpointKind,
translateRemotePairingFailureDescription
} from '@/lib/remote-pairing-copy'
export type RuntimeHostAccessFailure = {
kind: RemotePairingFailureKind
message: string
}
type RuntimeHostAccessFormProps = {
name: string
accessLink: string
busy: boolean
failure: RuntimeHostAccessFailure | null
onNameChange: (value: string) => void
onAccessLinkChange: (value: string) => void
onCancel: () => void
onSubmit: (allowLoopback: boolean) => void
}
export function RuntimeHostAccessForm({
name,
accessLink,
busy,
failure,
onNameChange,
onAccessLinkChange,
onCancel,
onSubmit
}: RuntimeHostAccessFormProps): React.JSX.Element {
const [allowLoopback, setAllowLoopback] = useState(false)
const parsed = useMemo(() => parseHostAccessLink(accessLink), [accessLink])
const tunnelOverrideEnabled =
allowLoopback && parsed.ok && parsed.value.endpointKind === 'loopback'
const loopbackBlocked =
parsed.ok && parsed.value.endpointKind === 'loopback' && !tunnelOverrideEnabled
const inputError = accessLink.trim() !== '' && !parsed.ok
const describedBy = failure
? 'runtime-server-verification-error'
: inputError
? 'runtime-server-access-link-error'
: loopbackBlocked
? 'runtime-server-loopback-error'
: 'runtime-server-access-link-help'
const canSubmit = name.trim() !== '' && parsed.ok && !loopbackBlocked && !busy
return (
<form
className="space-y-4 rounded-lg border border-border/50 bg-muted/20 p-4"
onSubmit={(event) => {
event.preventDefault()
if (canSubmit) {
onSubmit(tunnelOverrideEnabled)
}
}}
>
<div className="space-y-2 rounded-md border border-border/60 bg-background/60 p-3">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeHostAccessForm.getLink',
'Get an access link from the other host'
)}
</div>
<ol className="ml-4 list-decimal space-y-1 text-xs text-muted-foreground">
<li>
{translate(
'auto.components.settings.RuntimeHostAccessForm.stepOpenShare',
'Open Settings → Remote Orca Servers → Share this host.'
)}
</li>
<li>
{translate(
'auto.components.settings.RuntimeHostAccessForm.stepChooseAddress',
'Choose Another device and select a reachable address.'
)}
</li>
<li>
{translate(
'auto.components.settings.RuntimeHostAccessForm.stepCopyLink',
'Generate the link, then copy the “Pair another Orca client” link.'
)}
</li>
</ol>
</div>
<div className="grid gap-3 sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)]">
<div className="space-y-2">
<Label htmlFor="runtime-server-name">
{translate('auto.components.settings.RuntimeHostAccessForm.name', 'Name in Orca')}
</Label>
<Input
id="runtime-server-name"
value={name}
disabled={busy}
onChange={(event) => onNameChange(event.target.value)}
placeholder={translate(
'auto.components.settings.RuntimeHostAccessForm.namePlaceholder',
'Linux workstation'
)}
autoFocus
/>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.nameHelp',
'This only changes how the computer appears in Orca.'
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="runtime-server-access-link">
{translate('auto.components.settings.RuntimeHostAccessForm.accessLink', 'Access link')}
</Label>
<Input
id="runtime-server-access-link"
aria-invalid={inputError || loopbackBlocked || failure !== null}
aria-describedby={describedBy}
value={accessLink}
disabled={busy}
onChange={(event) => {
setAllowLoopback(false)
onAccessLinkChange(event.target.value)
}}
placeholder={translate(
'auto.components.settings.RuntimeHostAccessForm.accessLinkPlaceholder',
'orca://pair?code=...'
)}
className="min-w-0 font-mono"
/>
<p id="runtime-server-access-link-help" className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.accessLinkHelp',
'Orca shows the destination before connecting. Credentials stay hidden.'
)}
</p>
{inputError ? (
<p id="runtime-server-access-link-error" className="text-xs text-destructive">
{parsed.ok ? null : translateHostAccessLinkError(parsed.kind)}
</p>
) : null}
</div>
</div>
{parsed.ok ? (
<div className="space-y-1 rounded-md border border-border/60 bg-background/60 px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium">
<span>
{translate(
'auto.components.settings.RuntimeHostAccessForm.destination',
'Link destination'
)}
</span>
<Badge variant="outline">
{translateRemotePairingEndpointKind(parsed.value.endpointKind)}
</Badge>
</div>
<div className="font-mono text-sm" aria-live="polite">
{parsed.value.displayEndpoint}
</div>
</div>
) : null}
{loopbackBlocked && parsed.ok ? (
<div
id="runtime-server-loopback-error"
role="alert"
className="space-y-1 rounded-md border border-destructive/50 bg-destructive/5 p-3 text-sm"
>
<div className="font-medium text-destructive">
{translate(
'auto.components.settings.RuntimeHostAccessForm.loopbackTitle',
'This link points back to this device'
)}
</div>
<p>
{translate(
'auto.components.settings.RuntimeHostAccessForm.loopbackDescription',
'It uses {{endpoint}}, which points back to the device opening the link—not the other computer that created it.',
{ endpoint: parsed.value.displayEndpoint }
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.loopbackRecovery',
'On the other computer, create a new link using Another device and choose its Tailscale or LAN address.'
)}
</p>
<details className="pt-1 text-xs">
<summary className="cursor-pointer font-medium">
{translate(
'auto.components.settings.RuntimeHostAccessForm.connectionDetails',
'Connection details'
)}
</summary>
<dl className="mt-2 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-muted-foreground">
<dt>
{translate(
'auto.components.settings.RuntimeHostAccessForm.destination',
'Link destination'
)}
</dt>
<dd className="font-mono text-foreground">{parsed.value.displayEndpoint}</dd>
<dt>
{translate(
'auto.components.settings.RuntimeHostAccessForm.endpointKind',
'Endpoint kind'
)}
</dt>
<dd className="text-foreground">
{translateRemotePairingEndpointKind(parsed.value.endpointKind)}
</dd>
<dt>
{translate(
'auto.components.settings.RuntimeHostAccessForm.networkConnection',
'Network connection'
)}
</dt>
<dd className="text-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.notAttempted',
'Not attempted'
)}
</dd>
</dl>
</details>
</div>
) : null}
{failure ? (
<div
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>
) : null}
<details className="group text-xs">
<summary className="flex cursor-pointer list-none items-center gap-1 font-medium text-muted-foreground">
{translate('auto.components.settings.RuntimeHostAccessForm.advanced', 'Advanced')}
<ChevronDown className="size-3.5 transition-transform group-open:rotate-180" />
</summary>
{parsed.ok && parsed.value.endpointKind === 'loopback' ? (
<label className="mt-3 flex items-start gap-2 rounded-md border border-border/60 p-3">
<Checkbox
checked={allowLoopback}
disabled={busy}
onCheckedChange={(checked) => setAllowLoopback(checked === true)}
/>
<span className="space-y-1">
<span className="block font-medium text-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.sshTunnel',
'I am using an SSH tunnel to this local address'
)}
</span>
<span className="block text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.sshTunnelHelp',
'Keep the tunnel active while using this connection.'
)}
</span>
</span>
</label>
) : (
<p className="mt-2 text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.headlessHelp',
'Using headless orca serve? Run orca serve --pairing-address <reachable-host> on the other computer.'
)}
</p>
)}
</details>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
{translate('auto.components.settings.RuntimeHostAccessForm.cancel', 'Cancel')}
</Button>
<Button type="submit" size="sm" disabled={!canSubmit}>
{busy ? <Loader2 className="animate-spin" /> : <Plus />}
{tunnelOverrideEnabled
? translate(
'auto.components.settings.RuntimeHostAccessForm.addWithTunnel',
'Add host using tunnel'
)
: translate('auto.components.settings.RuntimeHostAccessForm.addHost', 'Add host')}
</Button>
</div>
</form>
)
}
@@ -0,0 +1,68 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '../ui/tooltip'
import {
RuntimePairingGeneratorForm,
type RuntimePairingIntent
} from './RuntimePairingGeneratorForm'
function renderForm(
intent: RuntimePairingIntent,
selectedAddress: string,
generated?: {
address: string
runtimePairingUrl: string
webClientUrl: string
}
): string {
return renderToStaticMarkup(
<TooltipProvider>
<RuntimePairingGeneratorForm
intent={intent}
loopbackAddress="127.0.0.1"
networkInterfaces={[{ name: 'tailscale0', address: '100.76.32.125' }]}
selectedAddress={selectedAddress}
refreshingNetworkInterfaces={false}
isGeneratingPairing={false}
webClientUrl={generated?.webClientUrl ?? null}
runtimePairingUrl={generated?.runtimePairingUrl ?? null}
copiedTarget={null}
generatedAddress={generated?.address ?? null}
onIntentChange={vi.fn()}
onSelectedAddressChange={vi.fn()}
onRefreshNetworkInterfaces={vi.fn()}
onGenerate={vi.fn()}
onCopy={vi.fn()}
/>
</TooltipProvider>
)
}
describe('RuntimePairingGeneratorForm', () => {
it('uses detected interfaces for another-device intent', () => {
const markup = renderForm('another', '100.76.32.125')
expect(markup).toContain('role="combobox"')
expect(markup).not.toContain('id="runtime-pairing-custom-address"')
})
it('requires a dedicated value for custom-address intent', () => {
const emptyMarkup = renderForm('custom', '')
expect(emptyMarkup).toContain('id="runtime-pairing-custom-address"')
expect(emptyMarkup).toContain('disabled=""')
const populatedMarkup = renderForm('custom', 'openclaw.example.ts.net')
expect(populatedMarkup).toContain('value="openclaw.example.ts.net"')
expect(populatedMarkup).not.toContain('disabled=""')
})
it('hides generated links after the selected address changes', () => {
const markup = renderForm('another', '100.76.32.125', {
address: '192.168.1.10',
runtimePairingUrl: 'orca://pair?code=stale-secret',
webClientUrl: 'https://example.test/?pair=stale-secret'
})
expect(markup).toContain('The connection address changed.')
expect(markup).not.toContain('stale-secret')
})
})
@@ -1,13 +1,18 @@
import { Loader2, RefreshCw } from 'lucide-react'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { AddressPicker, type AddressOption } from '../network/AddressPicker'
import { parseServerShareAddress } from '../../../../shared/network/server-share-address'
import { GeneratedUrlRow, UnavailableUrlRow } from './RuntimePairingGeneratedUrlRows'
import type { RuntimePairingIntent } from './runtime-pairing-link-state'
import { translate } from '@/i18n/i18n'
export type { RuntimePairingIntent } from './runtime-pairing-link-state'
type RuntimePairingGeneratorFormProps = {
intent: RuntimePairingIntent
loopbackAddress: string
networkInterfaces: { name: string; address: string }[]
selectedAddress: string
@@ -16,6 +21,8 @@ type RuntimePairingGeneratorFormProps = {
webClientUrl: string | null
runtimePairingUrl: string | null
copiedTarget: 'web' | 'pairing' | null
generatedAddress: string | null
onIntentChange: (intent: RuntimePairingIntent) => void
onSelectedAddressChange: (address: string) => void
onRefreshNetworkInterfaces: () => void
onGenerate: () => void
@@ -23,6 +30,7 @@ type RuntimePairingGeneratorFormProps = {
}
export function RuntimePairingGeneratorForm({
intent,
loopbackAddress,
networkInterfaces,
selectedAddress,
@@ -31,136 +39,279 @@ export function RuntimePairingGeneratorForm({
webClientUrl,
runtimePairingUrl,
copiedTarget,
generatedAddress,
onIntentChange,
onSelectedAddressChange,
onRefreshNetworkInterfaces,
onGenerate,
onCopy
}: RuntimePairingGeneratorFormProps): React.JSX.Element {
const options: AddressOption[] = [
{
value: loopbackAddress,
label: `${translate(
'auto.components.settings.RuntimePairingUrlGenerator.de6d5cff95',
'This computer ('
)}${loopbackAddress})`
},
...networkInterfaces.map((networkInterface) => ({
value: networkInterface.address,
label: `${networkInterface.name} (${networkInterface.address})`
}))
]
const options: AddressOption[] = networkInterfaces.map((networkInterface) => ({
value: networkInterface.address,
label: `${networkInterface.name} (${networkInterface.address})`
}))
const generatedIsCurrent = generatedAddress === selectedAddress
const staleGeneratedLink = generatedAddress !== null && !generatedIsCurrent
const customAddressResult =
intent === 'custom' ? parseServerShareAddress(selectedAddress) : { ok: true as const }
const customAddressInvalid = selectedAddress !== '' && !customAddressResult.ok
const canGenerate = selectedAddress !== '' && (intent !== 'custom' || customAddressResult.ok)
return (
<>
<div className="space-y-3">
<div className="space-y-1">
<Label id="runtime-pairing-address-label" htmlFor="runtime-pairing-address">
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.de77eb1b65',
'Connection address'
'auto.components.settings.RuntimePairingUrlGenerator.intentQuestion',
'Where will this link be opened?'
)}
</Label>
<div className="flex flex-wrap items-center gap-2">
<AddressPicker
id="runtime-pairing-address"
// Why: bounded width so a short value like "This computer
// (127.0.0.1)" doesn't stretch the trigger across the whole card;
// the value can grow up to the card edge before truncating.
className="min-w-[240px] max-w-full"
triggerAriaLabel={translate(
</legend>
<div className="grid gap-2 sm:grid-cols-3">
{(
[
[
'another',
translate(
'auto.components.settings.RuntimePairingUrlGenerator.anotherDevice',
'Another device'
),
translate(
'auto.components.settings.RuntimePairingUrlGenerator.anotherDeviceHelp',
'Tailscale, LAN, or another reachable address'
)
],
[
'local',
translate(
'auto.components.settings.RuntimePairingUrlGenerator.localOnly',
'This computer only'
),
translate(
'auto.components.settings.RuntimePairingUrlGenerator.localOnlyHelp',
'A browser or Orca client on this computer'
)
],
[
'custom',
translate(
'auto.components.settings.RuntimePairingUrlGenerator.customAddress',
'Custom address'
),
translate(
'auto.components.settings.RuntimePairingUrlGenerator.customAddressHelp',
'SSH tunnel, reverse proxy, or custom hostname'
)
]
] as const
).map(([value, label, description]) => (
<label
key={value}
className="flex cursor-pointer gap-2 rounded-md border border-border p-3 has-[:checked]:border-ring has-[:checked]:ring-1 has-[:checked]:ring-ring"
>
<input
type="radio"
name="runtime-pairing-intent"
value={value}
checked={intent === value}
onChange={() => onIntentChange(value)}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block text-xs font-medium">
{label}
{value === 'another' ? (
<span className="ml-1.5 text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.recommended',
'Recommended'
)}
</span>
) : null}
</span>
<span className="block text-[11px] text-muted-foreground">{description}</span>
</span>
</label>
))}
</div>
</fieldset>
{intent === 'local' ? (
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-xs">
<div className="font-medium">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.localLink',
'Local-only link'
)}
</div>
<p className="mt-1 text-muted-foreground">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.localLinkHelp',
'This link only works in a browser or Orca client running on this computer.'
)}
</p>
<div className="mt-2 font-mono">{loopbackAddress}</div>
</div>
) : intent === 'custom' ? (
<div className="space-y-1">
<Label htmlFor="runtime-pairing-custom-address">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-title',
'Custom connection address'
)}
</Label>
<Input
id="runtime-pairing-custom-address"
value={selectedAddress}
onChange={(event) => onSelectedAddressChange(event.target.value)}
placeholder={translate(
'auto.components.settings.RuntimePairingUrlGenerator.45cf476df3',
'host, host:port, or wss://host/path'
)}
className="font-mono"
aria-invalid={customAddressInvalid}
aria-describedby="runtime-pairing-custom-address-help"
autoFocus
/>
<p
id="runtime-pairing-custom-address-help"
className={
customAddressInvalid ? 'text-xs text-destructive' : 'text-xs text-muted-foreground'
}
>
{customAddressInvalid
? translate(
'auto.components.settings.RuntimePairingUrlGenerator.customInvalid',
'Enter a valid host, host:port, IPv6 address, or ws(s):// URL.'
)
: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-hint',
'Enter a host, host:port, or a ws(s):// URL.'
)}
</p>
</div>
) : (
<div className="space-y-1">
<Label id="runtime-pairing-address-label" htmlFor="runtime-pairing-address">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.de77eb1b65',
'Connection address'
)}
options={options}
value={selectedAddress}
onValueChange={onSelectedAddressChange}
placeholder=""
customInputId="runtime-pairing-custom-address"
formatCustomLabel={(address) =>
translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-option',
'{{address}} (custom)',
{ address }
)
}
addCustomLabel={translate(
'auto.components.settings.RuntimePairingUrlGenerator.add-custom',
'Add custom address…'
)}
validateCustom={parseServerShareAddress}
customDialogCopy={{
title: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-title',
'Custom connection address'
),
description: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-description',
'Advertise an address another device can reach — a LAN or Tailscale host, or a full ws(s):// URL.'
),
inputLabel: translate(
'auto.components.settings.RuntimePairingUrlGenerator.4531ea3158',
'Custom address'
),
placeholder: translate(
'auto.components.settings.RuntimePairingUrlGenerator.45cf476df3',
'host, host:port, or wss://host/path'
),
hint: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-hint',
'Enter a host, host:port, or a ws(s):// URL.'
),
cancel: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-cancel',
'Cancel'
),
confirm: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-use',
'Use address'
)
}}
/>
{/* Why: server sharing uses the same interface list as Mobile,
</Label>
<div className="flex flex-wrap items-center gap-2">
<AddressPicker
id="runtime-pairing-address"
// Why: bounded width so a short value like "This computer
// (127.0.0.1)" doesn't stretch the trigger across the whole card;
// the value can grow up to the card edge before truncating.
className="min-w-[240px] max-w-full"
triggerAriaLabel={translate(
'auto.components.settings.RuntimePairingUrlGenerator.de77eb1b65',
'Connection address'
)}
options={options}
value={selectedAddress}
onValueChange={onSelectedAddressChange}
placeholder=""
customInputId="runtime-pairing-custom-address"
formatCustomLabel={(address) =>
translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-option',
'{{address}} (custom)',
{ address }
)
}
addCustomLabel={translate(
'auto.components.settings.RuntimePairingUrlGenerator.add-custom',
'Use custom address…'
)}
validateCustom={parseServerShareAddress}
customDialogCopy={{
title: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-title',
'Custom connection address'
),
description: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-description',
'Advertise an address another device can reach — a LAN or Tailscale host, or a full ws(s):// URL.'
),
inputLabel: translate(
'auto.components.settings.RuntimePairingUrlGenerator.4531ea3158',
'Custom address'
),
placeholder: translate(
'auto.components.settings.RuntimePairingUrlGenerator.45cf476df3',
'host, host:port, or wss://host/path'
),
hint: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-hint',
'Enter a host, host:port, or a ws(s):// URL.'
),
cancel: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-cancel',
'Cancel'
),
confirm: translate(
'auto.components.settings.RuntimePairingUrlGenerator.custom-use',
'Use address'
)
}}
/>
{/* Why: server sharing uses the same interface list as Mobile,
and VPN/tailnet addresses can appear after Settings opens. */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.settings.RuntimePairingUrlGenerator.360c548cf3',
'Refresh connection addresses'
)}
className="text-muted-foreground"
>
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.360c548cf3',
'Refresh connection addresses'
)}
className="text-muted-foreground"
>
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
</TooltipContent>
</Tooltip>
</div>
{intent === 'another' &&
networkInterfaces.length === 0 &&
!refreshingNetworkInterfaces ? (
<p role="alert" className="text-xs text-destructive">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.360c548cf3',
'Refresh connection addresses'
'auto.components.settings.RuntimePairingUrlGenerator.noExternalAddress',
'No address for another device was found. Connect this computer to a LAN or Tailscale, refresh, or choose Custom address.'
)}
</TooltipContent>
</Tooltip>
</p>
) : null}
</div>
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.279e0dcb57',
'127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for another device.'
)}
</p>
)}
{staleGeneratedLink && selectedAddress !== '' ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.staleAddress',
'The connection address changed. Generate a new link for {{address}}.',
{ address: selectedAddress }
)}
</div>
) : null}
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={onGenerate}
disabled={isGeneratingPairing}
disabled={isGeneratingPairing || !canGenerate}
>
{isGeneratingPairing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{translate(
@@ -171,7 +322,7 @@ export function RuntimePairingGeneratorForm({
</div>
</div>
{webClientUrl ? (
{generatedIsCurrent && webClientUrl ? (
<GeneratedUrlRow
label={translate(
'auto.components.settings.RuntimePairingUrlGenerator.6b9ca3e69b',
@@ -185,7 +336,7 @@ export function RuntimePairingGeneratorForm({
copied={copiedTarget === 'web'}
onCopy={() => onCopy('web', webClientUrl)}
/>
) : runtimePairingUrl ? (
) : generatedIsCurrent && runtimePairingUrl ? (
<UnavailableUrlRow
label={translate(
'auto.components.settings.RuntimePairingUrlGenerator.6b9ca3e69b',
@@ -198,7 +349,7 @@ export function RuntimePairingGeneratorForm({
/>
) : null}
{runtimePairingUrl ? (
{generatedIsCurrent && runtimePairingUrl ? (
<GeneratedUrlRow
label={translate(
'auto.components.settings.RuntimePairingUrlGenerator.2e5c4e3c93',
@@ -0,0 +1,70 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
listNetworkInterfaces: vi.fn(),
listRuntimeAccessGrants: vi.fn()
}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
vi.mock('./RuntimeAccessGrantList', () => ({ RuntimeAccessGrantList: () => null }))
vi.mock('./RuntimePairingGeneratorForm', () => ({
RuntimePairingGeneratorForm: (props: { selectedAddress: string }) => (
<div data-testid="selected-address">{props.selectedAddress}</div>
)
}))
import { RuntimePairingUrlGenerator } from './RuntimePairingUrlGenerator'
import { runtimePairingLinkCache } from './runtime-pairing-link-state'
describe('RuntimePairingUrlGenerator', () => {
beforeEach(() => {
runtimePairingLinkCache.selectedAddress = '100.76.32.125'
runtimePairingLinkCache.customAddress = ''
runtimePairingLinkCache.intent = 'another'
runtimePairingLinkCache.generatedAddress = null
runtimePairingLinkCache.runtimePairingUrl = null
runtimePairingLinkCache.webClientUrl = null
runtimePairingLinkCache.runtimePairingDeviceId = null
mocks.listNetworkInterfaces.mockReset()
mocks.listRuntimeAccessGrants.mockReset().mockResolvedValue({ grants: [] })
Object.defineProperty(window, 'api', {
configurable: true,
value: {
mobile: {
listNetworkInterfaces: mocks.listNetworkInterfaces,
listRuntimeAccessGrants: mocks.listRuntimeAccessGrants
}
}
})
})
afterEach(() => {
cleanup()
})
it('keeps the cached address while interfaces are loading', async () => {
let resolveInterfaces!: (value: { interfaces: { name: string; address: string }[] }) => void
mocks.listNetworkInterfaces.mockReturnValue(
new Promise((resolve) => {
resolveInterfaces = resolve
})
)
render(<RuntimePairingUrlGenerator />)
await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce())
expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125')
resolveInterfaces({
interfaces: [{ name: 'tailscale0', address: '100.76.32.125' }]
})
await waitFor(() =>
expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125')
)
})
})
@@ -6,28 +6,15 @@ import { Label } from '../ui/label'
import { RuntimeAccessGrantList } from './RuntimeAccessGrantList'
import { translate } from '@/i18n/i18n'
import { RuntimePairingGeneratorForm } from './RuntimePairingGeneratorForm'
const LOOPBACK_ADDRESS = '127.0.0.1'
// Why: runtime pairing tokens stay valid in the main-process registry; keep the
// last displayed URL across settings collapse/navigation without less-protected storage.
const runtimePairingUrlCache: {
selectedAddress: string
runtimePairingUrl: string | null
webClientUrl: string | null
runtimePairingDeviceId: string | null
} = {
selectedAddress: LOOPBACK_ADDRESS,
runtimePairingUrl: null,
webClientUrl: null,
runtimePairingDeviceId: null
}
type RuntimePairingUrlGeneratorProps = {
framed?: boolean
showHeader?: boolean
showGeneratorForm?: boolean
}
import {
RUNTIME_PAIRING_LOOPBACK_ADDRESS,
cacheGeneratedRuntimePairingLink,
clearGeneratedRuntimePairingLink,
runtimePairingLinkCache,
selectRuntimePairingIntent,
type RuntimePairingIntent,
type RuntimePairingUrlGeneratorProps
} from './runtime-pairing-link-state'
export function RuntimePairingUrlGenerator({
framed = true,
@@ -37,15 +24,19 @@ export function RuntimePairingUrlGenerator({
const [networkInterfaces, setNetworkInterfaces] = useState<{ name: string; address: string }[]>(
[]
)
const [selectedAddress, setSelectedAddress] = useState(runtimePairingUrlCache.selectedAddress)
const [selectedAddress, setSelectedAddress] = useState(runtimePairingLinkCache.selectedAddress)
const [intent, setIntent] = useState<RuntimePairingIntent>(runtimePairingLinkCache.intent)
const [generatedAddress, setGeneratedAddress] = useState<string | null>(
runtimePairingLinkCache.generatedAddress
)
const [runtimePairingUrl, setRuntimePairingUrl] = useState<string | null>(
runtimePairingUrlCache.runtimePairingUrl
runtimePairingLinkCache.runtimePairingUrl
)
const [webClientUrl, setWebClientUrl] = useState<string | null>(
runtimePairingUrlCache.webClientUrl
runtimePairingLinkCache.webClientUrl
)
const [runtimePairingDeviceId, setRuntimePairingDeviceId] = useState<string | null>(
runtimePairingUrlCache.runtimePairingDeviceId
runtimePairingLinkCache.runtimePairingDeviceId
)
const [runtimeAccessGrants, setRuntimeAccessGrants] = useState<RuntimeAccessGrant[]>([])
const [isLoadingAccessGrants, setIsLoadingAccessGrants] = useState(false)
@@ -154,6 +145,20 @@ export function RuntimePairingUrlGenerator({
}
}, [loadNetworkInterfaces])
useEffect(() => {
if (intent !== 'another' || networkInterfaces.length === 0) {
return
}
const addressStillAvailable = networkInterfaces.some(
(networkInterface) => networkInterface.address === selectedAddress
)
if (!addressStillAvailable) {
const nextAddress = networkInterfaces[0]?.address ?? ''
runtimePairingLinkCache.selectedAddress = nextAddress
setSelectedAddress(nextAddress)
}
}, [intent, networkInterfaces, selectedAddress])
useEffect(() => {
void loadRuntimeAccessGrants()
return () => {
@@ -162,21 +167,26 @@ export function RuntimePairingUrlGenerator({
}, [loadRuntimeAccessGrants])
const clearGeneratedUrls = (): void => {
runtimePairingUrlCache.runtimePairingUrl = null
runtimePairingUrlCache.webClientUrl = null
runtimePairingUrlCache.runtimePairingDeviceId = null
clearGeneratedRuntimePairingLink()
if (mountedRef.current) {
setRuntimePairingUrl(null)
setWebClientUrl(null)
setRuntimePairingDeviceId(null)
setGeneratedAddress(null)
}
}
const generateRuntimePairingUrl = async (): Promise<void> => {
const address = selectedAddress.trim()
runtimePairingLinkCache.selectedAddress = address
setSelectedAddress(address)
if (intent === 'custom') {
runtimePairingLinkCache.customAddress = address
}
setIsGeneratingPairing(true)
try {
const result = await window.api.mobile.getRuntimePairingUrl({
address: selectedAddress,
address,
rotate: true
})
if (!result.available) {
@@ -191,13 +201,17 @@ export function RuntimePairingUrlGenerator({
}
return
}
runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl
runtimePairingUrlCache.webClientUrl = result.webClientUrl
runtimePairingUrlCache.runtimePairingDeviceId = result.deviceId
cacheGeneratedRuntimePairingLink({
address,
pairingUrl: result.pairingUrl,
webClientUrl: result.webClientUrl,
deviceId: result.deviceId
})
if (mountedRef.current) {
setRuntimePairingUrl(result.pairingUrl)
setWebClientUrl(result.webClientUrl)
setRuntimePairingDeviceId(result.deviceId)
setGeneratedAddress(address)
}
await loadRuntimeAccessGrants()
if (mountedRef.current) {
@@ -325,8 +339,29 @@ export function RuntimePairingUrlGenerator({
const sharedAccessClassName = showGeneratorForm ? 'border-t border-border/40 pt-3' : ''
const updateSelectedAddress = (address: string): void => {
runtimePairingUrlCache.selectedAddress = address
runtimePairingLinkCache.selectedAddress = address
setSelectedAddress(address)
if (
intent === 'another' &&
!networkInterfaces.some((networkInterface) => networkInterface.address === address)
) {
runtimePairingLinkCache.customAddress = address
runtimePairingLinkCache.intent = 'custom'
setIntent('custom')
} else if (intent === 'custom') {
runtimePairingLinkCache.customAddress = address
}
}
const updateIntent = (nextIntent: RuntimePairingIntent): void => {
setIntent(nextIntent)
setSelectedAddress(
selectRuntimePairingIntent(
nextIntent,
networkInterfaces,
runtimePairingLinkCache.customAddress
)
)
}
return (
@@ -349,7 +384,8 @@ export function RuntimePairingUrlGenerator({
) : null}
{showGeneratorForm ? (
<RuntimePairingGeneratorForm
loopbackAddress={LOOPBACK_ADDRESS}
intent={intent}
loopbackAddress={RUNTIME_PAIRING_LOOPBACK_ADDRESS}
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
refreshingNetworkInterfaces={refreshingNetworkInterfaces}
@@ -357,6 +393,8 @@ export function RuntimePairingUrlGenerator({
webClientUrl={webClientUrl}
runtimePairingUrl={runtimePairingUrl}
copiedTarget={copiedTarget}
generatedAddress={generatedAddress}
onIntentChange={updateIntent}
onSelectedAddressChange={updateSelectedAddress}
onRefreshNetworkInterfaces={() => void loadNetworkInterfaces({ showToastOnError: true })}
onGenerate={() => void generateRuntimePairingUrl()}
@@ -0,0 +1,64 @@
export const RUNTIME_PAIRING_LOOPBACK_ADDRESS = '127.0.0.1'
export type RuntimePairingIntent = 'another' | 'local' | 'custom'
export type RuntimePairingUrlGeneratorProps = {
framed?: boolean
showHeader?: boolean
showGeneratorForm?: boolean
}
// Why: pairing tokens remain in the main-process registry, so the last link can
// survive settings navigation without writing credential material to storage.
export const runtimePairingLinkCache: {
selectedAddress: string
customAddress: string
intent: RuntimePairingIntent
generatedAddress: string | null
runtimePairingUrl: string | null
webClientUrl: string | null
runtimePairingDeviceId: string | null
} = {
selectedAddress: '',
customAddress: '',
intent: 'another',
generatedAddress: null,
runtimePairingUrl: null,
webClientUrl: null,
runtimePairingDeviceId: null
}
export function clearGeneratedRuntimePairingLink(): void {
runtimePairingLinkCache.runtimePairingUrl = null
runtimePairingLinkCache.webClientUrl = null
runtimePairingLinkCache.runtimePairingDeviceId = null
runtimePairingLinkCache.generatedAddress = null
}
export function cacheGeneratedRuntimePairingLink(args: {
address: string
pairingUrl: string
webClientUrl: string | null
deviceId: string
}): void {
runtimePairingLinkCache.runtimePairingUrl = args.pairingUrl
runtimePairingLinkCache.webClientUrl = args.webClientUrl
runtimePairingLinkCache.runtimePairingDeviceId = args.deviceId
runtimePairingLinkCache.generatedAddress = args.address
}
export function selectRuntimePairingIntent(
intent: RuntimePairingIntent,
networkInterfaces: { address: string }[],
customAddress: string
): string {
runtimePairingLinkCache.intent = intent
const selectedAddress =
intent === 'local'
? RUNTIME_PAIRING_LOOPBACK_ADDRESS
: intent === 'another'
? (networkInterfaces[0]?.address ?? '')
: customAddress
runtimePairingLinkCache.selectedAddress = selectedAddress
return selectedAddress
}
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
@@ -19,6 +19,11 @@ import {
type EditingTarget
} from '../settings/ssh-target-draft'
import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types'
import { parseHostAccessLink } from '../../../../shared/remote-pairing-address'
import {
translateHostAccessLinkError,
translateRemotePairingFailureDescription
} from '@/lib/remote-pairing-copy'
import { RemoteServerFields, SshHostFields } from './AddRemoteHostFields'
export type AddRemoteHostMode = 'ssh' | 'server'
@@ -43,12 +48,18 @@ export function AddRemoteHostDialog({
const [sshForm, setSshForm] = useState<EditingTarget>(EMPTY_FORM)
const [serverName, setServerName] = useState('')
const [pairingCode, setPairingCode] = useState('')
const [allowLoopback, setAllowLoopback] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const parsedServerLink = useMemo(() => parseHostAccessLink(pairingCode), [pairingCode])
const serverFormCanSubmit =
serverName.trim() !== '' &&
parsedServerLink.ok &&
(parsedServerLink.value.endpointKind !== 'loopback' || allowLoopback)
const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata)
const recordSshRepoReadoptions = useAppStore((s) => s.recordSshRepoReadoptions)
const setRuntimeEnvironments = useAppStore((s) => s.setRuntimeEnvironments)
const refreshRuntimeEnvironmentStatus = useAppStore((s) => s.refreshRuntimeEnvironmentStatus)
const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const close = () => {
@@ -62,6 +73,7 @@ export function AddRemoteHostDialog({
setSshForm(EMPTY_FORM)
setServerName('')
setPairingCode('')
setAllowLoopback(false)
}
const refreshSshTargetMetadata = async () => {
@@ -195,16 +207,44 @@ export function AddRemoteHostDialog({
)
return
}
if (!parsedServerLink.ok) {
toast.error(translateHostAccessLinkError(parsedServerLink.kind))
return
}
if (parsedServerLink.value.endpointKind === 'loopback' && !allowLoopback) {
toast.error(
translate(
'auto.components.sidebar.AddRemoteHostDialog.loopbackBlocked',
'Enable the SSH tunnel override or create a new link using the other hosts Tailscale or LAN address.'
)
)
return
}
setIsSaving(true)
try {
const result = await window.api.runtimeEnvironments.addFromPairingCode({
const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name: trimmedName,
pairingCode: trimmedPairingCode
pairingCode: trimmedPairingCode,
allowLoopback
})
if (!result.ok) {
toast.error(
result.kind === 'environment-save-failed'
? result.message
: translateRemotePairingFailureDescription(
result.kind,
parsedServerLink.value.displayEndpoint
)
)
return
}
const environments = await window.api.runtimeEnvironments.list()
setRuntimeEnvironments(environments)
await refreshRuntimeEnvironmentStatus(result.environment.id)
setRuntimeEnvironmentStatus(result.environment.id, {
status: result.runtimeStatus,
checkedAt: Date.now()
})
toast.success(
translate('auto.components.sidebar.AddRemoteHostDialog.serverSaved', 'Remote server added.')
)
@@ -260,9 +300,15 @@ export function AddRemoteHostDialog({
<RemoteServerFields
name={serverName}
pairingCode={pairingCode}
parsedLink={parsedServerLink}
disabled={isSaving}
onNameChange={setServerName}
onPairingCodeChange={setPairingCode}
onPairingCodeChange={(value) => {
setPairingCode(value)
setAllowLoopback(false)
}}
allowLoopback={allowLoopback}
onAllowLoopbackChange={setAllowLoopback}
onSubmit={() => void saveRemoteServer()}
/>
) : (
@@ -306,7 +352,9 @@ export function AddRemoteHostDialog({
onClick={
renderMode === 'server' ? () => void saveRemoteServer() : () => void saveSshHost()
}
disabled={isSaving || isImporting}
disabled={
isSaving || isImporting || (renderMode === 'server' && !serverFormCanSubmit)
}
>
{isSaving
? translate('auto.components.sidebar.AddRemoteHostDialog.saving', 'Saving...')
@@ -0,0 +1,38 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../../shared/pairing'
import { parseHostAccessLink } from '../../../../shared/remote-pairing-address'
import { RemoteServerFields } from './AddRemoteHostFields'
function loopbackAccessLink(): string {
return encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: 'key',
scope: 'runtime'
})
}
describe('RemoteServerFields', () => {
it('associates blocked loopback guidance with the access-link input', () => {
const pairingCode = loopbackAccessLink()
const markup = renderToStaticMarkup(
<RemoteServerFields
name="Remote workstation"
pairingCode={pairingCode}
parsedLink={parseHostAccessLink(pairingCode)}
disabled={false}
onNameChange={vi.fn()}
onPairingCodeChange={vi.fn()}
allowLoopback={false}
onAllowLoopbackChange={vi.fn()}
onSubmit={vi.fn()}
/>
)
expect(markup).toContain('aria-invalid="true"')
expect(markup).toContain('aria-describedby="add-server-loopback-blocked"')
expect(markup).toContain('id="add-server-loopback-blocked"')
})
})
@@ -1,7 +1,14 @@
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { translate } from '@/i18n/i18n'
import {
translateHostAccessLinkError,
translateRemotePairingEndpointKind
} from '@/lib/remote-pairing-copy'
import type { ParseHostAccessLinkResult } from '../../../../shared/remote-pairing-address'
import { applyParsedSshHostInput, type EditingTarget } from '../settings/ssh-target-draft'
import { SshHostAdvancedFields } from '../settings/SshHostAdvancedFields'
@@ -120,18 +127,32 @@ export function SshHostFields({
export function RemoteServerFields({
name,
pairingCode,
parsedLink,
disabled,
onNameChange,
onPairingCodeChange,
allowLoopback,
onAllowLoopbackChange,
onSubmit
}: {
name: string
pairingCode: string
parsedLink: ParseHostAccessLinkResult
disabled: boolean
onNameChange: (value: string) => void
onPairingCodeChange: (value: string) => void
allowLoopback: boolean
onAllowLoopbackChange: (value: boolean) => void
onSubmit: () => void
}) {
const inputError = pairingCode.trim() !== '' && !parsedLink.ok
const loopbackBlocked =
parsedLink.ok && parsedLink.value.endpointKind === 'loopback' && !allowLoopback
const pairingCodeDescriptionId = inputError
? 'add-server-pairing-code-error'
: loopbackBlocked
? 'add-server-loopback-blocked'
: 'add-server-pairing-code-help'
return (
<form
className="space-y-3"
@@ -142,7 +163,7 @@ export function RemoteServerFields({
>
<div className="space-y-1.5">
<Label htmlFor="add-server-name">
{translate('auto.components.sidebar.AddRemoteHostDialog.serverName', 'Server name')}
{translate('auto.components.sidebar.AddRemoteHostDialog.serverName', 'Name in Orca')}
</Label>
<Input
id="add-server-name"
@@ -158,10 +179,12 @@ export function RemoteServerFields({
</div>
<div className="space-y-1.5">
<Label htmlFor="add-server-pairing-code">
{translate('auto.components.sidebar.AddRemoteHostDialog.pairingCode', 'Pairing code')}
{translate('auto.components.sidebar.AddRemoteHostDialog.pairingCode', 'Access link')}
</Label>
<Input
id="add-server-pairing-code"
aria-invalid={inputError || loopbackBlocked}
aria-describedby={pairingCodeDescriptionId}
value={pairingCode}
disabled={disabled}
onChange={(event) => onPairingCodeChange(event.target.value)}
@@ -171,20 +194,63 @@ export function RemoteServerFields({
)}
className="font-mono"
/>
<p className="text-xs text-muted-foreground">
{translate('auto.components.sidebar.AddRemoteHostDialog.pairingHelpPrefix', 'Run')}{' '}
<span className="font-mono">
{translate(
'auto.components.sidebar.AddRemoteHostDialog.pairingCommand',
'orca serve --pairing-address <host>'
)}
</span>{' '}
<p id="add-server-pairing-code-help" className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRemoteHostDialog.pairingHelpSuffix',
'on the server and paste the printed pairing URL.'
'Create this under Settings → Remote Orca Servers → Share this host on the other computer.'
)}
</p>
{inputError ? (
<p id="add-server-pairing-code-error" role="alert" className="text-xs text-destructive">
{parsedLink.ok ? null : translateHostAccessLinkError(parsedLink.kind)}
</p>
) : null}
</div>
{parsedLink.ok ? (
<div className="space-y-1 rounded-md border border-border/60 p-3">
<div className="flex items-center gap-2 text-xs font-medium">
{translate(
'auto.components.sidebar.AddRemoteHostDialog.linkDestination',
'Link destination'
)}
<Badge variant="outline">
{translateRemotePairingEndpointKind(parsedLink.value.endpointKind)}
</Badge>
</div>
<div className="font-mono text-sm">{parsedLink.value.displayEndpoint}</div>
{parsedLink.value.endpointKind === 'loopback' ? (
<label className="mt-2 flex items-start gap-2 text-xs">
<Checkbox
checked={allowLoopback}
disabled={disabled}
onCheckedChange={(checked) => onAllowLoopbackChange(checked === true)}
/>
<span>
<span className="block font-medium">
{translate(
'auto.components.sidebar.AddRemoteHostDialog.sshTunnel',
'I am using an SSH tunnel'
)}
</span>
<span className="text-muted-foreground">
{translate(
'auto.components.sidebar.AddRemoteHostDialog.sshTunnelHelp',
'Otherwise, this link points back to this device and cannot identify the other computer.'
)}
</span>
</span>
</label>
) : null}
</div>
) : null}
{loopbackBlocked ? (
<p id="add-server-loopback-blocked" role="alert" className="text-xs text-destructive">
{translate(
'auto.components.sidebar.AddRemoteHostDialog.loopbackBlocked',
'Enable the SSH tunnel override or create a new link using the other hosts Tailscale or LAN address.'
)}
</p>
) : null}
</form>
)
}
+100 -5
View File
@@ -172,7 +172,11 @@
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "Agent Session History is not available for this execution host.",
"runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected."
"runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected.",
"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."
},
"web": {
"preload": {
@@ -374,6 +378,18 @@
}
}
},
"remotePairingCopy": {
"invalidInput": "Enter an Orca access link or bare pairing code.",
"mobileOnly": "This link grants mobile-only access. Generate a link for another Orca client.",
"invalidDestination": "This access link contains an invalid destination.",
"unsupportedDestination": "This access link contains an unsupported destination.",
"nonConnectableDestination": "This access link contains a non-connectable destination.",
"loopback": "Loopback",
"tailscale": "Tailscale address",
"lan": "Private LAN address",
"public": "Public address",
"custom": "Custom hostname"
},
"ensure": {
"simulator": {
"tab": {
@@ -5033,7 +5049,11 @@
"sshImportFailed": "Failed to import SSH config.",
"importing": "Importing...",
"importSshConfig": "Import ~/.ssh/config",
"advanced": "Advanced"
"advanced": "Advanced",
"linkDestination": "Link destination",
"sshTunnel": "I am using an SSH tunnel",
"sshTunnelHelp": "Otherwise, this link points back to this device and cannot identify the other computer.",
"loopbackBlocked": "Enable the SSH tunnel override or create a new link using the other hosts Tailscale or LAN address."
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "Reconnection failed",
@@ -6740,7 +6760,22 @@
"updatingServers": "Updating servers…",
"orcaVersion": "Orca v{{value0}}",
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone."
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
"workflow": "Remote server workflow",
"connectWorkflow": "Connect to a host",
"connectWorkflowHelp": "This app joins another machine",
"shareWorkflow": "Share this host",
"shareWorkflowHelp": "Other devices join this machine",
"troubleshootWorkflow": "Connection troubleshooting",
"sshTunnelRequired": "SSH tunnel required",
"troubleshootTitle": "Create a new link on the other host",
"troubleshootDescription": "A link that uses 127.0.0.1 points back to the device opening it, not the computer that created it.",
"troubleshootStepShare": "On the other computer, open Share this host.",
"troubleshootStepAddress": "Choose Another device and select its Tailscale or LAN address.",
"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"
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "Copy {{value0}}"
@@ -6779,7 +6814,20 @@
"custom-description": "Advertise an address another device can reach — a LAN or Tailscale host, or a full ws(s):// URL.",
"custom-hint": "Enter a host, host:port, or a ws(s):// URL.",
"custom-cancel": "Cancel",
"custom-use": "Use address"
"custom-use": "Use address",
"intentQuestion": "Where will this link be opened?",
"anotherDevice": "Another device",
"anotherDeviceHelp": "Tailscale, LAN, or another reachable address",
"localOnly": "This computer only",
"localOnlyHelp": "A browser or Orca client on this computer",
"customAddress": "Custom address",
"customAddressHelp": "SSH tunnel, reverse proxy, or custom hostname",
"recommended": "Recommended",
"localLink": "Local-only link",
"localLinkHelp": "This link only works in a browser or Orca client running on this computer.",
"noExternalAddress": "No address for another device was found. Connect this computer to a LAN or Tailscale, refresh, or choose Custom address.",
"staleAddress": "The connection address changed. Generate a new link for {{address}}.",
"customInvalid": "Enter a valid host, host:port, IPv6 address, or ws(s):// URL."
},
"Settings": {
"3bf149e873": "Project Settings > {{value0}}",
@@ -9403,7 +9451,15 @@
"empty": "No temporary VM runtimes need cleanup.",
"copyCleanup": "Copy command",
"retry": "Retry cleanup",
"cleanup": "Cleanup"
"cleanup": "Cleanup",
"cloudVmLoadFailed": "Couldnt load Cloud VM runtimes.",
"cloudVmCleanupFailedToast": "Couldnt clean up Cloud VM runtime.",
"cloudVmMarkedCleaned": "Marked Cloud VM runtime as cleaned.",
"cloudVmCleaned": "Cleaned up Cloud VM runtime.",
"cloudVmTitle": "Cloud VM runtimes",
"cloudVmRefresh": "Refresh Cloud VM runtimes",
"cloudVmLoading": "Checking Cloud VM runtimes…",
"cloudVmEmpty": "No Cloud VM runtimes need cleanup."
},
"ephemeralVms": {
"search": {
@@ -9877,6 +9933,45 @@
"logs": "plugin logs",
"development": "development plugins"
}
},
"RuntimeHostAccessForm": {
"getLink": "Get an access link from the other host",
"stepOpenShare": "Open Settings → Remote Orca Servers → Share this host.",
"stepChooseAddress": "Choose Another device and select a reachable address.",
"stepCopyLink": "Generate the link, then copy the “Pair another Orca client” link.",
"name": "Name in Orca",
"namePlaceholder": "Linux workstation",
"nameHelp": "This only changes how the computer appears in Orca.",
"accessLink": "Access link",
"accessLinkPlaceholder": "orca://pair?code=...",
"accessLinkHelp": "Orca shows the destination before connecting. Credentials stay hidden.",
"destination": "Link destination",
"loopbackTitle": "This link points back to this device",
"loopbackDescription": "It uses {{endpoint}}, which points back to the device opening the link—not the other computer that created it.",
"loopbackRecovery": "On the other computer, create a new link using Another device and choose its Tailscale or LAN address.",
"identityMismatch": "The reached Orca host does not match this access link",
"identityMismatchHelp": "Orca reached {{endpoint}}, but that host does not match this link. Generate a new link on the other host.",
"invalidLink": "This access link is no longer valid",
"invalidLinkHelp": "Generate a new access link on the other host and try again.",
"incompatible": "Orca versions are not compatible",
"incompatibleHelp": "Update Orca on this device and the other host, then try again.",
"interrupted": "Connection interrupted",
"interruptedHelp": "The connection stopped during verification. Check the network or SSH tunnel and try again.",
"unavailable": "Host unavailable",
"unavailableHelp": "Make sure Orca is running on the other host and that the network or SSH tunnel can reach {{endpoint}}.",
"advanced": "Advanced",
"sshTunnel": "I am using an SSH tunnel to this local address",
"sshTunnelHelp": "Keep the tunnel active while using this connection.",
"headlessHelp": "Using headless orca serve? Run orca serve --pairing-address <reachable-host> on the other computer.",
"cancel": "Cancel",
"addWithTunnel": "Add host using tunnel",
"addHost": "Add host",
"connectionDetails": "Connection details",
"endpointKind": "Endpoint kind",
"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."
}
},
"right": {
+97 -5
View File
@@ -149,7 +149,10 @@
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "El historial de sesiones de Agents no está disponible para este host de ejecución.",
"runtimeEnvironmentManuallyDisconnected": "El entorno de ejecución se ha desconectado manualmente."
"runtimeEnvironmentManuallyDisconnected": "El entorno de ejecución se ha desconectado manualmente.",
"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."
},
"web": {
"preload": {
@@ -351,6 +354,18 @@
}
}
},
"remotePairingCopy": {
"invalidInput": "Introduce un enlace de acceso de Orca o un código de emparejamiento.",
"mobileOnly": "Este enlace concede acceso solo para dispositivos móviles. Genera un enlace para otro cliente de Orca.",
"invalidDestination": "Este enlace de acceso contiene un destino no válido.",
"unsupportedDestination": "Este enlace de acceso contiene un destino no compatible.",
"nonConnectableDestination": "Este enlace de acceso contiene un destino al que no se puede conectar.",
"loopback": "Bucle local",
"tailscale": "Dirección de Tailscale",
"lan": "Dirección LAN privada",
"public": "Dirección pública",
"custom": "Nombre de host personalizado"
},
"ensure": {
"simulator": {
"tab": {
@@ -5006,7 +5021,11 @@
"importing": "Importando...",
"importSshConfig": "Importar ~/.ssh/config",
"sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay.",
"advanced": "Advanced"
"advanced": "Advanced",
"linkDestination": "Destino del enlace",
"sshTunnel": "Estoy usando un túnel SSH",
"sshTunnelHelp": "De lo contrario, este enlace apunta a este dispositivo y no puede identificar el otro equipo.",
"loopbackBlocked": "Activa la opción de túnel SSH o crea un enlace nuevo con la dirección de Tailscale o LAN del otro host."
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "Error de reconexión",
@@ -6676,7 +6695,22 @@
"orcaVersion": "Orca v{{value0}}",
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
"updatingServers": "Updating servers…"
"updatingServers": "Updating servers…",
"workflow": "Flujo de servidor remoto",
"connectWorkflow": "Conectarse a un host",
"connectWorkflowHelp": "Esta aplicación se conecta a otra máquina",
"shareWorkflow": "Compartir este host",
"shareWorkflowHelp": "Otros dispositivos se conectan a esta máquina",
"troubleshootWorkflow": "Solución de problemas de conexión",
"sshTunnelRequired": "Se requiere un túnel SSH",
"troubleshootTitle": "Crear un enlace nuevo en el otro host",
"troubleshootDescription": "Un enlace que usa 127.0.0.1 apunta al dispositivo que lo abre, no al equipo que lo creó.",
"troubleshootStepShare": "En el otro equipo, abre Compartir este host.",
"troubleshootStepAddress": "Elige Otro dispositivo y selecciona su dirección de Tailscale o LAN.",
"troubleshootStepRegenerate": "Genera un enlace de acceso nuevo y usa aquí solo el más reciente.",
"troubleshootTunnel": "¿Usas un reenvío local SSH? Vuelve a Conectarse a un host, pega el enlace de bucle local y activa «Estoy usando un túnel SSH» en Avanzado.",
"cloudVmWorkflow": "VM en la nube",
"cloudVmWorkflowHelp": "Gestionar máquinas en la nube creadas mediante recetas"
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "Copiar {{value0}}"
@@ -6715,7 +6749,20 @@
"custom-description": "Anuncia una dirección que otro dispositivo pueda alcanzar: un host de LAN o Tailscale, o una URL ws(s):// completa.",
"custom-hint": "Introduce un host, host:puerto o una URL ws(s)://.",
"custom-cancel": "Cancelar",
"custom-use": "Usar dirección"
"custom-use": "Usar dirección",
"intentQuestion": "¿Dónde se abrirá este enlace?",
"anotherDevice": "Otro dispositivo",
"anotherDeviceHelp": "Tailscale, LAN u otra dirección accesible",
"localOnly": "Solo este equipo",
"localOnlyHelp": "Un navegador o cliente Orca en este equipo",
"customAddress": "Dirección personalizada",
"customAddressHelp": "Túnel SSH, proxy inverso o nombre de host personalizado",
"recommended": "Recomendado",
"localLink": "Enlace solo local",
"localLinkHelp": "Este enlace solo funciona en un navegador o cliente Orca que se ejecute en este equipo.",
"noExternalAddress": "No se encontró una dirección para otro dispositivo. Conecta este equipo a una LAN o a Tailscale, actualiza o elige Dirección personalizada.",
"staleAddress": "La dirección de conexión cambió. Genera un enlace nuevo para {{address}}.",
"customInvalid": "Introduce un host, host:puerto, dirección IPv6 o URL ws(s):// válidos."
},
"Settings": {
"3bf149e873": "Configuración del proyecto > {{value0}}",
@@ -9375,7 +9422,15 @@
"empty": "No hay runtimes de VM temporales que limpiar.",
"copyCleanup": "Copiar comando",
"retry": "Reintentar limpieza",
"cleanup": "Limpiar"
"cleanup": "Limpiar",
"cloudVmLoadFailed": "No se pudieron cargar los runtimes de VM en la nube.",
"cloudVmCleanupFailedToast": "No se pudo limpiar el runtime de VM en la nube.",
"cloudVmMarkedCleaned": "El runtime de VM en la nube se marcó como limpiado.",
"cloudVmCleaned": "Se limpió el runtime de VM en la nube.",
"cloudVmTitle": "Runtimes de VM en la nube",
"cloudVmRefresh": "Actualizar runtimes de VM en la nube",
"cloudVmLoading": "Comprobando runtimes de VM en la nube…",
"cloudVmEmpty": "Ningún runtime de VM en la nube necesita limpieza."
},
"ephemeralVms": {
"search": {
@@ -9849,6 +9904,43 @@
"logs": "registros de plugins",
"development": "plugins de desarrollo"
}
},
"RuntimeHostAccessForm": {
"getLink": "Obtener un enlace de acceso del otro host",
"stepOpenShare": "Abre Ajustes → Servidores remotos de Orca → Compartir este host.",
"stepChooseAddress": "Elige Otro dispositivo y selecciona una dirección accesible.",
"stepCopyLink": "Genera el enlace y, a continuación, copia el enlace «Emparejar otro cliente de Orca».",
"name": "Nombre en Orca",
"namePlaceholder": "Estación de trabajo Linux",
"nameHelp": "Esto solo cambia cómo aparece el equipo en Orca.",
"accessLink": "Enlace de acceso",
"accessLinkPlaceholder": "orca://pair?code=...",
"accessLinkHelp": "Orca muestra el destino antes de conectarse. Las credenciales permanecen ocultas.",
"destination": "Destino del enlace",
"loopbackTitle": "Este enlace apunta de nuevo a este dispositivo",
"loopbackDescription": "Usa {{endpoint}}, que apunta al dispositivo que abre el enlace, no al otro equipo que lo creó.",
"loopbackRecovery": "En el otro equipo, crea un enlace nuevo con Otro dispositivo y elige su dirección de Tailscale o LAN.",
"identityMismatch": "El host Orca alcanzado no coincide con este enlace de acceso",
"identityMismatchHelp": "Orca llegó a {{endpoint}}, pero ese host no coincide con este enlace. Genera un enlace nuevo en el otro host.",
"invalidLink": "Este enlace de acceso ya no es válido",
"invalidLinkHelp": "Genera un enlace de acceso nuevo en el otro host e inténtalo de nuevo.",
"incompatible": "Las versiones de Orca no son compatibles",
"incompatibleHelp": "Actualiza Orca en este dispositivo y en el otro host e inténtalo de nuevo.",
"interrupted": "Conexión interrumpida",
"interruptedHelp": "La conexión se detuvo durante la verificación. Comprueba la red o el túnel SSH e inténtalo de nuevo.",
"unavailable": "Host no disponible",
"unavailableHelp": "Asegúrate de que Orca se esté ejecutando en el otro host y de que la red o el túnel SSH puedan acceder a {{endpoint}}.",
"advanced": "Avanzado",
"sshTunnel": "Estoy usando un túnel SSH hacia esta dirección local",
"sshTunnelHelp": "Mantén el túnel activo mientras usas esta conexión.",
"headlessHelp": "¿Usas orca serve sin interfaz? Ejecuta orca serve --pairing-address <host-accesible> en el otro equipo.",
"cancel": "Cancelar",
"addWithTunnel": "Añadir host mediante túnel",
"addHost": "Añadir host",
"connectionDetails": "Detalles de conexión",
"endpointKind": "Tipo de destino",
"networkConnection": "Conexión de red",
"notAttempted": "No se intentó"
}
},
"right": {
+97 -5
View File
@@ -149,7 +149,10 @@
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "この実行ホストでは Agent セッション履歴を使用できません。",
"runtimeEnvironmentManuallyDisconnected": "ランタイム環境は手動で切断されています。"
"runtimeEnvironmentManuallyDisconnected": "ランタイム環境は手動で切断されています。",
"loopbackPairingBlocked": "このアクセスリンクはこのデバイス自身を指しています。",
"remotePairingUnreachable": "{{endpoint}} の Orca に接続できません。",
"remotePairingInvalidDetails": "このアクセスリンクには無効な接続情報が含まれています。"
},
"web": {
"preload": {
@@ -351,6 +354,18 @@
}
}
},
"remotePairingCopy": {
"invalidInput": "Orca のアクセスリンクまたはペアリングコードを入力してください。",
"mobileOnly": "このリンクはモバイル専用アクセスを許可します。別の Orca クライアント用のリンクを生成してください。",
"invalidDestination": "このアクセスリンクには無効な接続先が含まれています。",
"unsupportedDestination": "このアクセスリンクには未対応の接続先が含まれています。",
"nonConnectableDestination": "このアクセスリンクには接続できない接続先が含まれています。",
"loopback": "ループバック",
"tailscale": "Tailscale アドレス",
"lan": "プライベート LAN アドレス",
"public": "パブリックアドレス",
"custom": "カスタムホスト名"
},
"ensure": {
"simulator": {
"tab": {
@@ -5006,7 +5021,11 @@
"importing": "インポート中...",
"importSshConfig": "~/.ssh/config をインポートします",
"sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。",
"advanced": "Advanced"
"advanced": "詳細",
"linkDestination": "リンク先",
"sshTunnel": "SSH トンネルを使用しています",
"sshTunnelHelp": "それ以外の場合、このリンクはこのデバイス自身を指すため、もう一方のコンピューターを識別できません。",
"loopbackBlocked": "SSH トンネルの上書きを有効にするか、もう一方のホストの Tailscale または LAN アドレスを使って新しいリンクを作成してください。"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "再接続に失敗しました",
@@ -6698,7 +6717,22 @@
"orcaVersion": "Orca v{{value0}}",
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
"updatingServers": "Updating servers…"
"updatingServers": "Updating servers…",
"workflow": "リモートサーバーワークフロー",
"connectWorkflow": "ホストに接続",
"connectWorkflowHelp": "このアプリを別のマシンに接続します",
"shareWorkflow": "このホストを共有",
"shareWorkflowHelp": "他のデバイスをこのマシンに接続します",
"troubleshootWorkflow": "接続のトラブルシューティング",
"sshTunnelRequired": "SSH トンネルが必要です",
"troubleshootTitle": "もう一方のホストで新しいリンクを作成",
"troubleshootDescription": "127.0.0.1 を使うリンクは、作成したコンピューターではなく、リンクを開いたデバイス自身を指します。",
"troubleshootStepShare": "もう一方のコンピューターで「このホストを共有」を開きます。",
"troubleshootStepAddress": "「別のデバイス」を選び、Tailscale または LAN アドレスを選択します。",
"troubleshootStepRegenerate": "新しいアクセスリンクを生成し、ここでは最新のリンクだけを使います。",
"troubleshootTunnel": "SSH ローカルフォワードを使う場合は、「ホストに接続」に戻ってループバックリンクを貼り付け、「詳細」で「SSH トンネルを使用しています」を有効にします。",
"cloudVmWorkflow": "クラウド VM",
"cloudVmWorkflowHelp": "レシピで作成したクラウドマシンを管理"
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "{{value0}}をコピー"
@@ -6737,7 +6771,20 @@
"custom-description": "他のデバイスから到達できるアドレスを指定します。LAN や Tailscale のホスト、または完全な ws(s):// URL です。",
"custom-hint": "ホスト、host:port、または ws(s):// URL を入力してください。",
"custom-cancel": "キャンセル",
"custom-use": "アドレスを使用"
"custom-use": "アドレスを使用",
"intentQuestion": "このリンクはどこで開きますか?",
"anotherDevice": "別のデバイス",
"anotherDeviceHelp": "Tailscale、LAN、または到達可能な別のアドレス",
"localOnly": "このコンピューターのみ",
"localOnlyHelp": "このコンピューター上のブラウザーまたは Orca クライアント",
"customAddress": "カスタムアドレス",
"customAddressHelp": "SSH トンネル、リバースプロキシ、またはカスタムホスト名",
"recommended": "推奨",
"localLink": "ローカル専用リンク",
"localLinkHelp": "このリンクは、このコンピューターで実行中のブラウザーまたは Orca クライアントでのみ使えます。",
"noExternalAddress": "別のデバイス用のアドレスが見つかりません。このコンピューターを LAN または Tailscale に接続して更新するか、カスタムアドレスを選んでください。",
"staleAddress": "接続アドレスが変更されました。{{address}} 用の新しいリンクを生成してください。",
"customInvalid": "有効なホスト、ホスト:ポート、IPv6 アドレス、または ws(s):// URL を入力してください。"
},
"Settings": {
"3bf149e873": "プロジェクト設定 > {{value0}}",
@@ -9375,7 +9422,15 @@
"empty": "一時的な VM ランタイムにはクリーンアップが必要ありません。",
"copyCleanup": "コピーコマンド",
"retry": "クリーンアップを再試行します",
"cleanup": "掃除"
"cleanup": "クリーンアップ",
"cloudVmLoadFailed": "クラウド VM ランタイムを読み込めませんでした。",
"cloudVmCleanupFailedToast": "クラウド VM ランタイムをクリーンアップできませんでした。",
"cloudVmMarkedCleaned": "クラウド VM ランタイムをクリーンアップ済みとして記録しました。",
"cloudVmCleaned": "クラウド VM ランタイムをクリーンアップしました。",
"cloudVmTitle": "クラウド VM ランタイム",
"cloudVmRefresh": "クラウド VM ランタイムを更新",
"cloudVmLoading": "クラウド VM ランタイムを確認中…",
"cloudVmEmpty": "クリーンアップが必要なクラウド VM ランタイムはありません。"
},
"ephemeralVms": {
"search": {
@@ -9849,6 +9904,43 @@
"logs": "プラグインログ",
"development": "開発プラグイン"
}
},
"RuntimeHostAccessForm": {
"getLink": "もう一方のホストからアクセスリンクを取得",
"stepOpenShare": "設定 → リモート Orca サーバー → このホストを共有 を開きます。",
"stepChooseAddress": "「別のデバイス」を選び、到達可能なアドレスを選択します。",
"stepCopyLink": "リンクを生成し、「別の Orca クライアントをペアリング」リンクをコピーします。",
"name": "Orca での名前",
"namePlaceholder": "Linux ワークステーション",
"nameHelp": "これは Orca でのコンピューターの表示名だけを変更します。",
"accessLink": "アクセスリンク",
"accessLinkPlaceholder": "orca://pair?code=...",
"accessLinkHelp": "Orca は接続前に接続先を表示します。認証情報は非表示のままです。",
"destination": "リンク先",
"loopbackTitle": "このリンクはこのデバイス自身を指しています",
"loopbackDescription": "{{endpoint}} はリンクを開いたデバイス自身を指し、リンクを作成したもう一方のコンピューターには接続しません。",
"loopbackRecovery": "もう一方のコンピューターで「別のデバイス」を使って新しいリンクを作成し、Tailscale または LAN アドレスを選んでください。",
"identityMismatch": "接続した Orca ホストがこのアクセスリンクと一致しません",
"identityMismatchHelp": "{{endpoint}} の Orca に接続しましたが、そのホストはこのリンクと一致しません。もう一方のホストで新しいリンクを生成してください。",
"invalidLink": "このアクセスリンクは無効になっています",
"invalidLinkHelp": "もう一方のホストで新しいアクセスリンクを生成し、もう一度お試しください。",
"incompatible": "Orca のバージョンに互換性がありません",
"incompatibleHelp": "このデバイスともう一方のホストの Orca を更新し、もう一度お試しください。",
"interrupted": "接続が中断されました",
"interruptedHelp": "検証中に接続が停止しました。ネットワークまたは SSH トンネルを確認して、もう一度お試しください。",
"unavailable": "ホストを利用できません",
"unavailableHelp": "もう一方のホストで Orca が実行中であり、ネットワークまたは SSH トンネルから {{endpoint}} に到達できることを確認してください。",
"advanced": "詳細",
"sshTunnel": "このローカルアドレスへの SSH トンネルを使用しています",
"sshTunnelHelp": "この接続を使用している間はトンネルを維持してください。",
"headlessHelp": "ヘッドレスの orca serve を使う場合は、もう一方のコンピューターで orca serve --pairing-address <reachable-host> を実行してください。",
"cancel": "キャンセル",
"addWithTunnel": "トンネルを使ってホストを追加",
"addHost": "ホストを追加",
"connectionDetails": "接続の詳細",
"endpointKind": "接続先の種類",
"networkConnection": "ネットワーク接続",
"notAttempted": "未試行"
}
},
"right": {
+97 -5
View File
@@ -149,7 +149,10 @@
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "이 실행 호스트에서는 Agent 세션 기록을 사용할 수 없습니다.",
"runtimeEnvironmentManuallyDisconnected": "런타임 환경이 수동으로 연결 해제되었습니다."
"runtimeEnvironmentManuallyDisconnected": "런타임 환경이 수동으로 연결 해제되었습니다.",
"loopbackPairingBlocked": "이 액세스 링크는 이 기기 자체를 가리킵니다.",
"remotePairingUnreachable": "{{endpoint}}의 Orca에 연결할 수 없습니다.",
"remotePairingInvalidDetails": "이 액세스 링크에 잘못된 연결 정보가 포함되어 있습니다."
},
"web": {
"preload": {
@@ -351,6 +354,18 @@
}
}
},
"remotePairingCopy": {
"invalidInput": "Orca 액세스 링크 또는 페어링 코드를 입력하세요.",
"mobileOnly": "이 링크는 모바일 전용 액세스를 허용합니다. 다른 Orca 클라이언트용 링크를 생성하세요.",
"invalidDestination": "이 액세스 링크에 잘못된 대상이 포함되어 있습니다.",
"unsupportedDestination": "이 액세스 링크에 지원되지 않는 대상이 포함되어 있습니다.",
"nonConnectableDestination": "이 액세스 링크에 연결할 수 없는 대상이 포함되어 있습니다.",
"loopback": "루프백",
"tailscale": "Tailscale 주소",
"lan": "사설 LAN 주소",
"public": "공용 주소",
"custom": "사용자 지정 호스트 이름"
},
"ensure": {
"simulator": {
"tab": {
@@ -5006,7 +5021,11 @@
"importing": "가져오는 중...",
"importSshConfig": "~/.ssh/config 가져오기",
"sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다.",
"advanced": "Advanced"
"advanced": "고급",
"linkDestination": "링크 대상",
"sshTunnel": "SSH 터널을 사용하고 있습니다",
"sshTunnelHelp": "그렇지 않으면 이 링크는 이 기기 자체를 가리키므로 다른 컴퓨터를 식별할 수 없습니다.",
"loopbackBlocked": "SSH 터널 재정의를 활성화하거나 다른 호스트의 Tailscale 또는 LAN 주소를 사용해 새 링크를 만드세요."
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "재연결 실패",
@@ -6661,7 +6680,22 @@
"orcaVersion": "Orca v{{value0}}",
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
"updatingServers": "Updating servers…"
"updatingServers": "Updating servers…",
"workflow": "원격 서버 워크플로",
"connectWorkflow": "호스트에 연결",
"connectWorkflowHelp": "이 앱을 다른 머신에 연결합니다",
"shareWorkflow": "이 호스트 공유",
"shareWorkflowHelp": "다른 기기를 이 머신에 연결합니다",
"troubleshootWorkflow": "연결 문제 해결",
"sshTunnelRequired": "SSH 터널 필요",
"troubleshootTitle": "다른 호스트에서 새 링크 만들기",
"troubleshootDescription": "127.0.0.1을 사용하는 링크는 링크를 만든 컴퓨터가 아니라 링크를 여는 기기 자체를 가리킵니다.",
"troubleshootStepShare": "다른 컴퓨터에서 ‘이 호스트 공유’를 엽니다.",
"troubleshootStepAddress": "‘다른 기기’를 선택하고 Tailscale 또는 LAN 주소를 선택합니다.",
"troubleshootStepRegenerate": "새 액세스 링크를 생성하고 여기에서는 가장 최신 링크만 사용합니다.",
"troubleshootTunnel": "SSH 로컬 포워딩을 사용하나요? ‘호스트에 연결’로 돌아가 루프백 링크를 붙여 넣은 다음 ‘고급’에서 ‘SSH 터널을 사용하고 있습니다’를 활성화하세요.",
"cloudVmWorkflow": "클라우드 VM",
"cloudVmWorkflowHelp": "레시피로 생성한 클라우드 머신 관리"
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "{{value0}} 복사"
@@ -6700,7 +6734,20 @@
"custom-description": "다른 기기가 연결할 수 있는 주소를 알립니다. LAN 또는 Tailscale 호스트, 또는 전체 ws(s):// URL입니다.",
"custom-hint": "호스트, host:port 또는 ws(s):// URL을 입력하세요.",
"custom-cancel": "취소",
"custom-use": "주소 사용"
"custom-use": "주소 사용",
"intentQuestion": "이 링크를 어디에서 열 예정인가요?",
"anotherDevice": "다른 기기",
"anotherDeviceHelp": "Tailscale, LAN 또는 연결 가능한 다른 주소",
"localOnly": "이 컴퓨터만",
"localOnlyHelp": "이 컴퓨터의 브라우저 또는 Orca 클라이언트",
"customAddress": "사용자 지정 주소",
"customAddressHelp": "SSH 터널, 리버스 프록시 또는 사용자 지정 호스트 이름",
"recommended": "권장",
"localLink": "로컬 전용 링크",
"localLinkHelp": "이 링크는 이 컴퓨터에서 실행 중인 브라우저 또는 Orca 클라이언트에서만 작동합니다.",
"noExternalAddress": "다른 기기용 주소를 찾지 못했습니다. 이 컴퓨터를 LAN 또는 Tailscale에 연결한 후 새로 고치거나 사용자 지정 주소를 선택하세요.",
"staleAddress": "연결 주소가 변경되었습니다. {{address}}용 새 링크를 생성하세요.",
"customInvalid": "올바른 호스트, 호스트:포트, IPv6 주소 또는 ws(s):// URL을 입력하세요."
},
"Settings": {
"3bf149e873": "프로젝트 설정 > {{value0}}",
@@ -9375,7 +9422,15 @@
"empty": "임시 VM 런타임은 정리가 필요하지 않습니다.",
"copyCleanup": "복사 명령",
"retry": "정리 다시 시도",
"cleanup": "대청소"
"cleanup": "정리",
"cloudVmLoadFailed": "클라우드 VM 런타임을 불러올 수 없습니다.",
"cloudVmCleanupFailedToast": "클라우드 VM 런타임을 정리할 수 없습니다.",
"cloudVmMarkedCleaned": "클라우드 VM 런타임을 정리됨으로 표시했습니다.",
"cloudVmCleaned": "클라우드 VM 런타임을 정리했습니다.",
"cloudVmTitle": "클라우드 VM 런타임",
"cloudVmRefresh": "클라우드 VM 런타임 새로 고침",
"cloudVmLoading": "클라우드 VM 런타임 확인 중…",
"cloudVmEmpty": "정리가 필요한 클라우드 VM 런타임이 없습니다."
},
"ephemeralVms": {
"search": {
@@ -9849,6 +9904,43 @@
"logs": "플러그인 로그",
"development": "개발 플러그인"
}
},
"RuntimeHostAccessForm": {
"getLink": "다른 호스트에서 액세스 링크 가져오기",
"stepOpenShare": "설정 → 원격 Orca 서버 → 이 호스트 공유를 엽니다.",
"stepChooseAddress": "‘다른 기기’를 선택하고 연결 가능한 주소를 선택합니다.",
"stepCopyLink": "링크를 생성한 다음 “다른 Orca 클라이언트 페어링” 링크를 복사하세요.",
"name": "Orca에서 사용할 이름",
"namePlaceholder": "Linux 워크스테이션",
"nameHelp": "Orca에서 이 컴퓨터가 표시되는 이름만 변경합니다.",
"accessLink": "액세스 링크",
"accessLinkPlaceholder": "orca://pair?code=...",
"accessLinkHelp": "Orca는 연결하기 전에 대상을 표시하며 자격 증명은 숨겨 둡니다.",
"destination": "링크 대상",
"loopbackTitle": "이 링크는 이 기기 자체를 가리킵니다",
"loopbackDescription": "{{endpoint}}는 링크를 연 기기 자체를 가리키며, 링크를 만든 다른 컴퓨터에는 연결되지 않습니다.",
"loopbackRecovery": "다른 컴퓨터에서 ‘다른 기기’를 사용해 새 링크를 만들고 Tailscale 또는 LAN 주소를 선택하세요.",
"identityMismatch": "연결된 Orca 호스트가 이 액세스 링크와 일치하지 않습니다",
"identityMismatchHelp": "{{endpoint}}의 Orca에 연결했지만 해당 호스트가 이 링크와 일치하지 않습니다. 다른 호스트에서 새 링크를 생성하세요.",
"invalidLink": "이 액세스 링크는 더 이상 유효하지 않습니다",
"invalidLinkHelp": "다른 호스트에서 새 액세스 링크를 생성한 후 다시 시도하세요.",
"incompatible": "Orca 버전이 호환되지 않습니다",
"incompatibleHelp": "이 기기와 다른 호스트의 Orca를 업데이트한 후 다시 시도하세요.",
"interrupted": "연결이 중단되었습니다",
"interruptedHelp": "확인 중 연결이 중단되었습니다. 네트워크 또는 SSH 터널을 확인한 후 다시 시도하세요.",
"unavailable": "호스트를 사용할 수 없습니다",
"unavailableHelp": "다른 호스트에서 Orca가 실행 중이고 네트워크 또는 SSH 터널을 통해 {{endpoint}}에 연결할 수 있는지 확인하세요.",
"advanced": "고급",
"sshTunnel": "이 로컬 주소에 SSH 터널을 사용하고 있습니다",
"sshTunnelHelp": "이 연결을 사용하는 동안 터널을 유지하세요.",
"headlessHelp": "헤드리스 orca serve를 사용하나요? 다른 컴퓨터에서 orca serve --pairing-address <reachable-host>를 실행하세요.",
"cancel": "취소",
"addWithTunnel": "터널을 사용해 호스트 추가",
"addHost": "호스트 추가",
"connectionDetails": "연결 세부 정보",
"endpointKind": "대상 유형",
"networkConnection": "네트워크 연결",
"notAttempted": "시도하지 않음"
}
},
"right": {
+130 -38
View File
@@ -149,7 +149,10 @@
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "此执行主机不支持 Agent 会话历史记录。",
"runtimeEnvironmentManuallyDisconnected": "运行时环境已手动断开连接。"
"runtimeEnvironmentManuallyDisconnected": "运行时环境已手动断开连接。",
"loopbackPairingBlocked": "此访问链接会指回此设备。",
"remotePairingUnreachable": "无法连接 {{endpoint}} 上的 Orca。",
"remotePairingInvalidDetails": "此访问链接包含无效的连接信息。"
},
"web": {
"preload": {
@@ -351,6 +354,18 @@
}
}
},
"remotePairingCopy": {
"invalidInput": "请输入 Orca 访问链接或配对代码。",
"mobileOnly": "此链接仅授予移动端访问权限。请为另一个 Orca 客户端生成链接。",
"invalidDestination": "此访问链接包含无效的目标地址。",
"unsupportedDestination": "此访问链接包含不受支持的目标地址。",
"nonConnectableDestination": "此访问链接包含无法连接的目标地址。",
"loopback": "环回地址",
"tailscale": "Tailscale 地址",
"lan": "专用 LAN 地址",
"public": "公网地址",
"custom": "自定义主机名"
},
"ensure": {
"simulator": {
"tab": {
@@ -5006,7 +5021,11 @@
"importing": "正在导入...",
"importSshConfig": "导入 ~/.ssh/config",
"sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。",
"advanced": "Advanced"
"advanced": "Advanced",
"linkDestination": "链接目标",
"sshTunnel": "我正在使用 SSH 隧道",
"sshTunnelHelp": "否则,此链接会指回此设备,无法识别另一台计算机。",
"loopbackBlocked": "启用 SSH 隧道选项,或使用另一台主机的 Tailscale 或 LAN 地址创建新链接。"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "重新连接失败",
@@ -6653,15 +6672,30 @@
"advertiseThisApp": "将此应用作为服务器公布",
"advertiseThisAppHelp": "创建访问链接,让浏览器、移动客户端或另一个 Orca 客户端连接回这个正在运行的应用。",
"runtimeReachable": "可连接到 {{value0}}。",
"updateAvailableOne": "1 update available",
"updatesAvailable": "{{value0}} updates available",
"versionUnavailable": "Orca version unavailable",
"updateServer": "Update",
"reviewServerUpdates": "Server updates",
"updateAvailableOne": "1 个更新可用",
"updatesAvailable": "{{value0}} 个更新可用",
"versionUnavailable": "Orca 版本不可用",
"updateServer": "更新",
"reviewServerUpdates": "服务器更新",
"orcaVersion": "Orca v{{value0}}",
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
"updatingServers": "Updating servers…"
"removeActiveServerBlocked": "请先在“高级”中选择另一个活动服务器,然后再移除此服务器。",
"removeActiveServerDescription": "请先在“高级”中选择另一个活动服务器,然后再移除此服务器。现有的主机会话将保留。",
"updatingServers": "正在更新服务器…",
"workflow": "远程服务器工作流程",
"connectWorkflow": "连接到主机",
"connectWorkflowHelp": "此应用加入另一台机器",
"shareWorkflow": "共享此主机",
"shareWorkflowHelp": "其他设备加入此机器",
"troubleshootWorkflow": "连接故障排除",
"sshTunnelRequired": "需要 SSH 隧道",
"troubleshootTitle": "在另一台主机上创建新链接",
"troubleshootDescription": "使用 127.0.0.1 的链接会指向打开它的设备,而不是创建它的计算机。",
"troubleshootStepShare": "在另一台计算机上,打开“共享此主机”。",
"troubleshootStepAddress": "选择“其他设备”,然后选择其 Tailscale 或 LAN 地址。",
"troubleshootStepRegenerate": "生成新的访问链接,并仅在此处使用最新的链接。",
"troubleshootTunnel": "正在使用 SSH 本地转发?返回“连接到主机”,粘贴环回链接,然后在“高级”下启用“我正在使用 SSH 隧道”。",
"cloudVmWorkflow": "云虚拟机",
"cloudVmWorkflowHelp": "管理由配方创建的云端机器"
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "复制 {{value0}}"
@@ -6700,7 +6734,20 @@
"custom-description": "公布其他设备可以访问的地址:LAN 或 Tailscale 主机,或完整的 ws(s):// URL。",
"custom-hint": "请输入主机、host:port 或 ws(s):// URL。",
"custom-cancel": "取消",
"custom-use": "使用地址"
"custom-use": "使用地址",
"intentQuestion": "此链接将在哪里打开?",
"anotherDevice": "其他设备",
"anotherDeviceHelp": "Tailscale、LAN 或其他可达地址",
"localOnly": "仅此计算机",
"localOnlyHelp": "此计算机上的浏览器或 Orca 客户端",
"customAddress": "自定义地址",
"customAddressHelp": "SSH 隧道、反向代理或自定义主机名",
"recommended": "推荐",
"localLink": "仅本地链接",
"localLinkHelp": "此链接仅在运行于此计算机上的浏览器或 Orca 客户端中有效。",
"noExternalAddress": "未找到其他设备的地址。将此计算机连接到 LAN 或 Tailscale,刷新,或选择“自定义地址”。",
"staleAddress": "连接地址已更改。为 {{address}} 生成新链接。",
"customInvalid": "请输入有效的主机、主机:端口、IPv6 地址或 ws(s):// URL。"
},
"Settings": {
"3bf149e873": "项目设置 > {{value0}}",
@@ -6777,7 +6824,7 @@
"dev": "开发工具",
"devDescription": "仅用于调试 UI 状态的开发工具。",
"linearTitle": "Linear",
"linearDescription": "Give agents the skill to read and update your linked Linear tickets."
"linearDescription": "赋予智能体读取和更新您关联的 Linear 工单的能力。"
},
"SettingsFormControls": {
"42a4d15a30": "没有匹配的字体。",
@@ -9375,7 +9422,15 @@
"empty": "没有需要清理的临时 VM 运行时。",
"copyCleanup": "复制命令",
"retry": "重试清理",
"cleanup": "清理"
"cleanup": "清理",
"cloudVmLoadFailed": "无法加载云虚拟机运行时。",
"cloudVmCleanupFailedToast": "无法清理云虚拟机运行时。",
"cloudVmMarkedCleaned": "已将云虚拟机运行时标记为已清理。",
"cloudVmCleaned": "已清理云虚拟机运行时。",
"cloudVmTitle": "云虚拟机运行时",
"cloudVmRefresh": "刷新云虚拟机运行时",
"cloudVmLoading": "正在检查云虚拟机运行时…",
"cloudVmEmpty": "没有需要清理的云虚拟机运行时。"
},
"ephemeralVms": {
"search": {
@@ -9558,25 +9613,25 @@
"reviewUpdateOne": "1 update available"
},
"RemoteServerUpdateDialog": {
"versionUnavailable": "Version unavailable",
"restartingHelp": "Waiting for the replacement server to reconnect on the new version.",
"retry": "Retry",
"update": "Update",
"title": "Update Remote Orca Servers",
"description": "Review paired servers and update supported installs from this Orca client.",
"restartWarning": "Updating restarts these servers. {{value0}} live tabs and {{value1}} terminal panes may briefly disconnect.",
"checking": "Checking paired servers…",
"empty": "No paired Remote Orca Servers.",
"checkAgain": "Check again",
"updating": "Updating servers…",
"updateAll": "Update {{value0}} servers",
"versionUnavailable": "版本不可用",
"restartingHelp": "正在等待替代服务器在新版本上重新连接。",
"retry": "重试",
"update": "更新",
"title": "更新远程 Orca 服务器",
"description": "查看已配对的服务器,并从此 Orca 客户端更新受支持的安装。",
"restartWarning": "更新将重启这些服务器。{{value0}} 个活动标签页和 {{value1}} 个终端窗格可能会短暂断开连接。",
"checking": "正在检查已配对的服务器…",
"empty": "没有已配对的远程 Orca 服务器。",
"checkAgain": "再次检查",
"updating": "正在更新服务器…",
"updateAll": "更新 {{value0}} 台服务器",
"noUpdates": "没有可用更新",
"updateOne": "Update server",
"downloadProgress": "{{value0}} download progress",
"liveTabOne": "1 live tab",
"liveTabs": "{{value0}} live tabs",
"livePaneOne": "1 live pane",
"livePanes": "{{value0}} live panes"
"updateOne": "更新服务器",
"downloadProgress": "{{value0}} 下载进度",
"liveTabOne": "1 个活动标签页",
"liveTabs": "{{value0}} 个活动标签页",
"livePaneOne": "1 个活动窗格",
"livePanes": "{{value0}} 个活动窗格"
},
"RemoteServerUpdateStatus": {
"checking": "检查中…",
@@ -9586,13 +9641,13 @@
"offline": "离线",
"queued": "已排队",
"checkingUpdate": "正在检查更新…",
"downloading": "Downloading…",
"restarting": "Restarting…",
"updated": "Updated",
"failed": "Update failed",
"serviceManagerHelp": "Update Orca through the service manager that starts this server.",
"unpackedHelp": "Development builds must be updated from their source checkout.",
"legacyHelp": "Update this server manually once to enable remote updates."
"downloading": "正在下载…",
"restarting": "正在重启…",
"updated": "已更新",
"failed": "更新失败",
"serviceManagerHelp": "通过启动此服务器的服务管理器更新 Orca。",
"unpackedHelp": "开发版本必须从其源代码检出进行更新。",
"legacyHelp": "手动更新此服务器一次以启用远程更新。"
},
"BrowserLinkRoutingModifierSetting": {
"titleSystem": "Hold Shift to open in your web browser",
@@ -9849,6 +9904,43 @@
"logs": "插件日志",
"development": "开发插件"
}
},
"RuntimeHostAccessForm": {
"getLink": "从另一台主机获取访问链接",
"stepOpenShare": "打开设置 → 远程 Orca 服务器 → 共享此主机。",
"stepChooseAddress": "选择“其他设备”,然后选择一个可达地址。",
"stepCopyLink": "生成链接,然后复制“配对另一个 Orca 客户端”链接。",
"name": "在 Orca 中的名称",
"namePlaceholder": "Linux 工作站",
"nameHelp": "这仅更改计算机在 Orca 中的显示方式。",
"accessLink": "访问链接",
"accessLinkPlaceholder": "orca://pair?code=...",
"accessLinkHelp": "Orca 会在连接前显示目标。凭据保持隐藏。",
"destination": "链接目标",
"loopbackTitle": "此链接指向此设备",
"loopbackDescription": "{{endpoint}} 会指向打开链接的设备,而不是创建链接的另一台计算机。",
"loopbackRecovery": "在另一台计算机上,使用“其他设备”创建新链接,并选择其 Tailscale 或 LAN 地址。",
"identityMismatch": "到达的 Orca 主机与此访问链接不匹配",
"identityMismatchHelp": "已连接到 {{endpoint}} 上的 Orca,但该主机与此链接不匹配。请在另一台主机上生成新链接。",
"invalidLink": "此访问链接不再有效",
"invalidLinkHelp": "请在另一台主机上生成新的访问链接,然后重试。",
"incompatible": "Orca 版本不兼容",
"incompatibleHelp": "请更新此设备和另一台主机上的 Orca,然后重试。",
"interrupted": "连接中断",
"interruptedHelp": "验证期间连接已中断。请检查网络或 SSH 隧道,然后重试。",
"unavailable": "主机不可用",
"unavailableHelp": "请确保 Orca 正在另一台主机上运行,并且网络或 SSH 隧道可以连接到 {{endpoint}}。",
"advanced": "高级",
"sshTunnel": "我正在使用 SSH 隧道连接到此本地地址",
"sshTunnelHelp": "使用此连接时保持隧道活跃。",
"headlessHelp": "正在使用无头 orca serve?在另一台计算机上运行 orca serve --pairing-address <可达主机>。",
"cancel": "取消",
"addWithTunnel": "使用隧道添加主机",
"addHost": "添加主机",
"connectionDetails": "连接详情",
"endpointKind": "端点类型",
"networkConnection": "网络连接",
"notAttempted": "未尝试"
}
},
"right": {
@@ -58,11 +58,6 @@ const CASES: GuardCase[] = [
afterFallback: 'such as',
label: 'such as main'
},
{
file: 'components/settings/RuntimeEnvironmentsPane.tsx',
afterFallback: 'Run',
label: 'Run orca serve'
},
{
file: 'components/settings/AutoRenameBranchFromWorkSetting.tsx',
afterFallback: 'Use',
@@ -0,0 +1,91 @@
import type {
HostAccessLinkErrorKind,
RemotePairingEndpointKind
} from '../../../shared/remote-pairing-address'
import type { RemotePairingFailureKind } from '../../../shared/remote-pairing-verification'
import { translate } from '@/i18n/i18n'
export function translateHostAccessLinkError(kind: HostAccessLinkErrorKind): string {
switch (kind) {
case 'invalid-input':
return translate(
'auto.lib.remotePairingCopy.invalidInput',
'Enter an Orca access link or bare pairing code.'
)
case 'mobile-only':
return translate(
'auto.lib.remotePairingCopy.mobileOnly',
'This link grants mobile-only access. Generate a link for another Orca client.'
)
case 'invalid-destination':
return translate(
'auto.lib.remotePairingCopy.invalidDestination',
'This access link contains an invalid destination.'
)
case 'unsupported-destination':
return translate(
'auto.lib.remotePairingCopy.unsupportedDestination',
'This access link contains an unsupported destination.'
)
case 'non-connectable-destination':
return translate(
'auto.lib.remotePairingCopy.nonConnectableDestination',
'This access link contains a non-connectable destination.'
)
}
}
export function translateRemotePairingEndpointKind(kind: RemotePairingEndpointKind): string {
switch (kind) {
case 'loopback':
return translate('auto.lib.remotePairingCopy.loopback', 'Loopback')
case 'tailscale':
return translate('auto.lib.remotePairingCopy.tailscale', 'Tailscale address')
case 'lan':
return translate('auto.lib.remotePairingCopy.lan', 'Private LAN address')
case 'public':
return translate('auto.lib.remotePairingCopy.public', 'Public address')
case 'custom':
return translate('auto.lib.remotePairingCopy.custom', 'Custom hostname')
}
}
export function translateRemotePairingFailureDescription(
kind: RemotePairingFailureKind,
endpoint: string | null
): string {
switch (kind) {
case 'host-identity-mismatch':
return translate(
'auto.components.settings.RuntimeHostAccessForm.identityMismatchHelp',
'Orca reached {{endpoint}}, but that host does not match this link. Generate a new link on the other host.',
{ endpoint: endpoint ?? 'Orca' }
)
case 'access-link-invalid':
return translate(
'auto.components.settings.RuntimeHostAccessForm.invalidLinkHelp',
'Generate a new access link on the other host and try again.'
)
case 'protocol-incompatible':
return translate(
'auto.components.settings.RuntimeHostAccessForm.incompatibleHelp',
'Update Orca on this device and the other host, then try again.'
)
case 'connection-interrupted':
return translate(
'auto.components.settings.RuntimeHostAccessForm.interruptedHelp',
'The connection stopped during verification. Check the network or SSH tunnel and try again.'
)
case 'environment-save-failed':
return translate(
'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 'host-unreachable':
return translate(
'auto.components.settings.RuntimeHostAccessForm.unavailableHelp',
'Make sure Orca is running on the other host and that the network or SSH tunnel can reach {{endpoint}}.',
{ endpoint: endpoint ?? 'Orca' }
)
}
}
@@ -5,6 +5,7 @@ import type { PreloadApi } from '../../../preload/api-types'
import type { FeatureInteractionState } from '../../../shared/feature-interactions'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION } from '../../../shared/protocol-version'
const TEST_COMMIT_OID = '0123456789abcdef0123456789abcdef01234567'
@@ -200,6 +201,7 @@ describe('web before-unload persistence', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.doUnmock('./web-runtime-client')
})
it('persists final UI and host-partitioned sessions synchronously', async () => {
@@ -531,6 +533,187 @@ describe('web runtime environment identity', () => {
)
}
)
it('keeps the current host when verification rejects an incompatible replacement', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(): Promise<RuntimeRpcResponse<unknown>> {
return Promise.resolve({
id: 'status',
ok: true,
result: { runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 },
_meta: { runtimeId: 'runtime-old' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const previousStored = globals.storage.getItem('orca.web.runtimeEnvironment.v1')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name: 'Incompatible server',
pairingCode: encodePairingCode()
})
).resolves.toMatchObject({ ok: false, kind: 'protocol-incompatible' })
expect(globals.storage.getItem('orca.web.runtimeEnvironment.v1')).toBe(previousStored)
await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([
{ id: 'web-server-a' }
])
})
it('keeps the current host when browser storage rejects a verified replacement', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(): Promise<RuntimeRpcResponse<unknown>> {
return Promise.resolve({
id: 'status',
ok: true,
result: {
runtimeId: 'runtime-new',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION
},
_meta: { runtimeId: 'runtime-new' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
vi.spyOn(globals.storage, 'setItem').mockImplementation(() => {
throw new Error('Browser storage is full.')
})
await expect(
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name: 'Verified replacement',
pairingCode: encodePairingCode()
})
).resolves.toMatchObject({
ok: false,
kind: 'environment-save-failed',
message: 'Orca verified the host but could not save it. Check browser storage and try again.'
})
await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([
{ id: 'web-server-a' }
])
})
it('requires an explicit loopback override and persists the SSH dependency', async () => {
const call = vi.fn().mockResolvedValue({
id: 'status',
ok: true,
result: {
runtimeId: 'runtime-new',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION
},
_meta: { runtimeId: 'runtime-new' }
})
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call = call
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const pairingCode = encodePairingCode({ endpoint: 'ws://127.0.0.1:6768' })
await expect(
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name: 'Tunnel server',
pairingCode
})
).resolves.toMatchObject({ ok: false, kind: 'host-unreachable' })
expect(call).not.toHaveBeenCalled()
await expect(
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name: 'Tunnel server',
pairingCode,
allowLoopback: true
})
).resolves.toMatchObject({
ok: true,
environment: { connectionDependency: 'ssh-tunnel' }
})
expect(call).toHaveBeenCalledOnce()
expect(
JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}')
).toMatchObject({ connectionDependency: 'ssh-tunnel' })
})
it('returns a structured failure when the browser client cannot be constructed', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
constructor() {
throw new Error('Invalid public key: expected 32 bytes, got 3')
}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name: 'Broken server',
pairingCode: encodePairingCode()
})
).resolves.toMatchObject({ ok: false, kind: 'access-link-invalid' })
await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([
{ id: 'web-server-a' }
])
})
it('classifies coded browser authorization failures without relying on copy', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(): Promise<RuntimeRpcResponse<unknown>> {
return Promise.reject(
Object.assign(new Error('Access grant rejected.'), { code: 'unauthorized' })
)
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name: 'Expired server',
pairingCode: encodePairingCode()
})
).resolves.toMatchObject({
ok: false,
kind: 'access-link-invalid',
message: 'Access grant rejected.'
})
})
})
describe('web browser-local port capability', () => {
+104
View File
@@ -7,6 +7,8 @@ import type {
NativeChatAppendedMessages
} from '../../../preload/api-types'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import { parseHostAccessLink } from '../../../shared/remote-pairing-address'
import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification'
import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types'
import type {
AiVaultPrepareSessionResumeArgs,
@@ -113,6 +115,7 @@ import {
import { parseWebPairingInput } from './web-pairing'
import { copyClipboardTextViaExecCommand } from './web-clipboard-copy-fallback'
import { WebRuntimeClient } from './web-runtime-client'
import { isWebRuntimeUnauthorizedError } from './web-runtime-client-error'
import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue'
import {
assertClipboardTextWriteWithinLimitWithYield,
@@ -135,6 +138,7 @@ import {
} from '../../../shared/feature-interactions'
import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours'
import { translate } from '@/i18n/i18n'
import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy'
import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults'
import {
parseRuntimeNativeChatReadSessionResult,
@@ -1361,6 +1365,106 @@ function createRuntimeEnvironmentsApi(): NonNullable<Partial<PreloadApi>['runtim
saveStoredWebRuntimeEnvironment(activeEnvironment)
return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) }
},
verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => {
const parsed = parseHostAccessLink(pairingCode)
if (!parsed.ok) {
return {
ok: false,
kind: 'access-link-invalid',
message: translateHostAccessLinkError(parsed.kind)
}
}
if (parsed.value.endpointKind === 'loopback' && !allowLoopback) {
return {
ok: false,
kind: 'host-unreachable',
message: translate(
'auto.web.webPreloadApi.loopbackPairingBlocked',
'This access link points back to this device.'
)
}
}
let client: WebRuntimeClient | null = null
let runtimeStatus: RuntimeStatus
try {
client = new WebRuntimeClient(parsed.value.pairing)
const response = (await client.call('status.get', undefined, {
timeoutMs: 15_000
})) as RuntimeRpcResponse<RuntimeStatus>
if (!response.ok) {
return {
ok: false,
kind: 'connection-interrupted',
message: response.error.message
}
}
const statusVerification = verifyRemotePairingRuntimeStatus(response.result)
if (!statusVerification.ok) {
return statusVerification
}
runtimeStatus = statusVerification.runtimeStatus
} catch (error) {
if (error instanceof Error && error.message.startsWith('Invalid public key')) {
return {
ok: false,
kind: 'access-link-invalid',
message: translate(
'auto.web.webPreloadApi.remotePairingInvalidDetails',
'This access link contains invalid connection details.'
)
}
}
if (
isWebRuntimeUnauthorizedError(error) ||
(error instanceof Error && error.message.startsWith('Unauthorized.'))
) {
return {
ok: false,
kind: 'access-link-invalid',
message: error.message
}
}
return {
ok: false,
kind: 'host-unreachable',
message: translate(
'auto.web.webPreloadApi.remotePairingUnreachable',
'Cannot reach Orca at {{endpoint}}.',
{ endpoint: parsed.value.displayEndpoint }
)
}
} finally {
client?.close()
}
const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true
const nextEnvironment = createStoredWebRuntimeEnvironment({
name,
offer: parsed.value.pairing,
previousEnvironment: activeEnvironment,
...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {})
})
// Why: a browser storage failure must leave the currently active host usable.
try {
saveStoredWebRuntimeEnvironment(nextEnvironment)
} catch {
return {
ok: false,
kind: 'environment-save-failed',
message: translate(
'auto.web.webPreloadApi.remotePairingSaveFailed',
'Orca verified the host but could not save it. Check browser storage and try again.'
)
}
}
manuallyDisconnectedEnvironmentIds.clear()
closeActiveRuntimeClients()
activeEnvironment = nextEnvironment
return {
ok: true,
environment: redactStoredWebRuntimeEnvironment(nextEnvironment),
runtimeStatus
}
},
resolve: async ({ selector }) =>
redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)),
remove: async ({ selector }) => {
@@ -0,0 +1,25 @@
const UNAUTHORIZED_MESSAGE = 'Unauthorized. Pair this web client again.'
export class WebRuntimeClientError extends Error {
constructor(
message: string,
readonly code: 'unauthorized'
) {
super(message)
this.name = 'WebRuntimeClientError'
}
}
export function createWebRuntimeUnauthorizedError(): WebRuntimeClientError {
return new WebRuntimeClientError(UNAUTHORIZED_MESSAGE, 'unauthorized')
}
export function isWebRuntimeUnauthorizedError(
error: unknown
): error is Error & { code: 'unauthorized' } {
return (
error instanceof Error &&
'code' in error &&
(error as { code?: unknown }).code === 'unauthorized'
)
}
+7 -6
View File
@@ -15,6 +15,7 @@ import {
publicKeyToBase64
} from './web-e2ee'
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error'
type WebRuntimeConnectionState =
| 'disconnected'
@@ -478,7 +479,7 @@ export class WebRuntimeClient {
} else if (control.type === 'e2ee_error' || control.error?.code === 'unauthorized') {
this.intentionallyClosed = true
this.setState('auth-failed')
this.rejectAllPending('Unauthorized. Pair this web client again.')
this.rejectAllPending(createWebRuntimeUnauthorizedError())
this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.')
this.ws?.close()
}
@@ -530,7 +531,7 @@ export class WebRuntimeClient {
if (isRuntimeFailureResponse(response) && response.error.code === 'unauthorized') {
this.intentionallyClosed = true
this.setState('auth-failed')
this.rejectAllPending('Unauthorized. Pair this web client again.')
this.rejectAllPending(createWebRuntimeUnauthorizedError())
this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.')
this.ws?.close()
return
@@ -584,7 +585,7 @@ export class WebRuntimeClient {
return Promise.resolve()
}
if (this.state === 'auth-failed') {
return Promise.reject(new Error('Unauthorized. Pair this web client again.'))
return Promise.reject(createWebRuntimeUnauthorizedError())
}
if (this.intentionallyClosed) {
return Promise.reject(new Error('Remote Orca runtime connection closed.'))
@@ -658,7 +659,7 @@ export class WebRuntimeClient {
waiter.resolve()
}
} else if (next === 'auth-failed') {
this.rejectAllWaiters(new Error('Unauthorized. Pair this web client again.'))
this.rejectAllWaiters(createWebRuntimeUnauthorizedError())
}
}
@@ -667,8 +668,8 @@ export class WebRuntimeClient {
return `web-rpc-${this.requestCounter}-${Date.now()}`
}
private rejectAllPending(reason: string): void {
const error = new Error(reason)
private rejectAllPending(reason: string | Error): void {
const error = typeof reason === 'string' ? new Error(reason) : reason
for (const [id, pending] of this.pending) {
this.pending.delete(id)
window.clearTimeout(pending.timeout)
@@ -59,6 +59,7 @@ export function createStoredWebRuntimeEnvironment(args: {
name: string
offer: WebPairingOffer
previousEnvironment?: StoredWebRuntimeEnvironment | null
connectionDependency?: 'ssh-tunnel'
}): StoredWebRuntimeEnvironment {
const id = `web-${createBrowserUuid()}`
const now = Date.now()
@@ -70,6 +71,7 @@ export function createStoredWebRuntimeEnvironment(args: {
updatedAt: now,
lastUsedAt: null,
runtimeId: null,
...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}),
...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}),
preferredEndpointId: `ws-${id}`,
endpoints: [
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'
import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../mobile-pairing-protocol-limits'
import { parseServerShareAddress } from './server-share-address'
describe('parseServerShareAddress', () => {
@@ -16,19 +17,41 @@ describe('parseServerShareAddress', () => {
expect(parseServerShareAddress('my-host:443').ok).toBe(true)
})
it('accepts IPv6 literals with an optional port', () => {
expect(parseServerShareAddress('fd7a:115c:a1e0::1').ok).toBe(true)
expect(parseServerShareAddress('[fd7a:115c:a1e0::1]:6768').ok).toBe(true)
})
it('accepts ws:// and wss:// URLs', () => {
expect(parseServerShareAddress('wss://my-host/path').ok).toBe(true)
expect(parseServerShareAddress('ws://192.168.1.50:6768').ok).toBe(true)
})
it('rejects WebSocket URLs with fragments', () => {
expect(parseServerShareAddress('wss://my-host/path#fragment').ok).toBe(false)
})
it('trims surrounding whitespace', () => {
expect(parseServerShareAddress(' my-host:8080 ')).toEqual({ ok: true, value: 'my-host:8080' })
})
it('rejects empty, whitespace-containing, and malformed input', () => {
for (const bad of ['', ' ', 'has space', 'http://my-host', 'wss://', ':6768']) {
for (const bad of [
'',
' ',
'has space',
'http://my-host',
'wss://',
':6768',
'0.0.0.0',
'::',
'my-host:0',
'999.999.999.999',
'wss://user:password@my-host'
]) {
expect(parseServerShareAddress(bad).ok).toBe(false)
}
expect(parseServerShareAddress('a'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS + 1)).ok).toBe(false)
})
it('rejects an out-of-range port', () => {
+4 -34
View File
@@ -1,42 +1,12 @@
// Why: shared validator for the "Share this Orca server" custom address. The
// field accepts a bare host, host:port, or a full ws(s):// URL — looser than
// the mobile pairing grammar because the target is a transport endpoint, not
// just an IP. Kept pure so the renderer and any future caller agree.
import { parseManualNetworkAddress } from './manual-address'
export type ParseServerShareAddressResult = { ok: true; value: string } | { ok: false }
const HOST_LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
const HOSTNAME = `${HOST_LABEL}(?:\\.${HOST_LABEL})*`
const IPV4 = '(?:\\d{1,3}\\.){3}\\d{1,3}'
const HOST = `(?:${HOSTNAME}|${IPV4})`
const PORT = '[0-9]{1,5}'
const HOST_OR_HOST_PORT = new RegExp(`^${HOST}(?::${PORT})?$`)
export function parseServerShareAddress(input: string): ParseServerShareAddressResult {
const trimmed = input.trim()
if (trimmed === '' || /\s/.test(trimmed)) {
if (/^https?:\/\//i.test(trimmed)) {
return { ok: false }
}
// Full ws(s):// URL — defer to the URL parser, which validates host/port/path.
if (/^wss?:\/\//i.test(trimmed)) {
try {
const url = new URL(trimmed)
return url.hostname !== '' ? { ok: true, value: trimmed } : { ok: false }
} catch {
return { ok: false }
}
}
// Bare host or host:port. Reject an out-of-range port early.
const match = trimmed.match(HOST_OR_HOST_PORT)
if (!match) {
return { ok: false }
}
const portPart = trimmed.includes(':') ? trimmed.slice(trimmed.lastIndexOf(':') + 1) : null
if (portPart !== null && Number(portPart) > 65535) {
return { ok: false }
}
return { ok: true, value: trimmed }
const result = parseManualNetworkAddress(trimmed)
return result.ok ? { ok: true, value: result.address } : { ok: false }
}
+14 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
encodePairingOffer,
decodePairingOffer,
@@ -22,6 +22,19 @@ describe('pairing offer', () => {
expect(decoded).toEqual(offer)
})
it('decodes in browser runtimes without Buffer', () => {
const url = encodePairingOffer(offer)
const nodeBuffer = Buffer
let decoded: PairingOffer | null = null
try {
vi.stubGlobal('Buffer', undefined)
decoded = decodePairingOffer(url)
} finally {
vi.stubGlobal('Buffer', nodeBuffer)
}
expect(decoded).toEqual(offer)
})
it('preserves optional device scope metadata', () => {
const scopedOffer: PairingOffer = { ...offer, scope: 'mobile' }
expect(decodePairingOffer(encodePairingOffer(scopedOffer))).toEqual(scopedOffer)
+6 -1
View File
@@ -89,6 +89,11 @@ function decodePairingBase64(base64url: string): PairingOffer {
throw new Error('Invalid pairing code')
}
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const json = Buffer.from(base64, 'base64').toString('utf-8')
const json =
typeof Buffer === 'undefined'
? new TextDecoder().decode(
Uint8Array.from(atob(base64), (character) => character.charCodeAt(0))
)
: Buffer.from(base64, 'base64').toString('utf-8')
return PairingOfferSchema.parse(JSON.parse(json))
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from './pairing'
import { classifyRemotePairingHostname, parseHostAccessLink } from './remote-pairing-address'
function accessLink(endpoint: string): string {
return encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint,
deviceToken: 'token',
publicKeyB64: 'key',
scope: 'runtime'
})
}
describe('remote pairing address', () => {
it.each([
['127.0.0.1', 'loopback'],
['localhost', 'loopback'],
['localhost.', 'loopback'],
['api.localhost', 'loopback'],
['api.localhost.', 'loopback'],
['localhost.localdomain', 'loopback'],
['localhost6', 'loopback'],
['ip6-localhost', 'loopback'],
['::1', 'loopback'],
['::ffff:7f00:1', 'loopback'],
['100.76.32.125', 'tailscale'],
['::ffff:644c:207d', 'tailscale'],
['192.168.1.20', 'lan'],
['10.0.0.8', 'lan'],
['fd7a:115c:a1e0::1', 'lan'],
['fe80::1', 'lan'],
['orca.example.com', 'public'],
['devbox', 'custom']
] as const)('classifies %s as %s', (hostname, expected) => {
expect(classifyRemotePairingHostname(hostname)).toBe(expected)
})
it('extracts a sanitized display endpoint without credentials', () => {
expect(parseHostAccessLink(accessLink('wss://orca.example.com/runtime'))).toEqual({
ok: true,
value: {
pairing: expect.objectContaining({ endpoint: 'wss://orca.example.com/runtime' }),
displayEndpoint: 'orca.example.com',
endpointKind: 'public'
}
})
})
it('keeps IPv6 brackets in the display endpoint', () => {
const result = parseHostAccessLink(accessLink('ws://[fd7a:115c:a1e0::1]:6768'))
expect(result.ok && result.value.displayEndpoint).toBe('[fd7a:115c:a1e0::1]:6768')
})
it('rejects invalid and unsupported endpoints', () => {
expect(parseHostAccessLink('not-a-link')).toMatchObject({
ok: false,
kind: 'invalid-input'
})
expect(parseHostAccessLink(accessLink('https://orca.example.com'))).toMatchObject({
ok: false,
kind: 'unsupported-destination'
})
expect(parseHostAccessLink(accessLink('wss://orca.example.com/#fragment'))).toMatchObject({
ok: false,
kind: 'unsupported-destination'
})
expect(parseHostAccessLink(accessLink('ws://[::ffff:0.0.0.0]:6768'))).toMatchObject({
ok: false,
kind: 'non-connectable-destination'
})
expect(parseHostAccessLink(accessLink('wss://orca.example.com:0'))).toMatchObject({
ok: false,
kind: 'non-connectable-destination'
})
})
it('blocks absolute localhost names used in access links', () => {
expect(parseHostAccessLink(accessLink('ws://localhost.:6768'))).toMatchObject({
ok: true,
value: { endpointKind: 'loopback' }
})
expect(parseHostAccessLink(accessLink('ws://api.localhost.:6768'))).toMatchObject({
ok: true,
value: { endpointKind: 'loopback' }
})
})
it('rejects mobile-only access grants', () => {
const link = encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint: 'wss://orca.example.com',
deviceToken: 'token',
publicKeyB64: 'key',
scope: 'mobile'
})
expect(parseHostAccessLink(link)).toMatchObject({ ok: false, kind: 'mobile-only' })
})
})
+147
View File
@@ -0,0 +1,147 @@
import { parsePairingCode, type PairingOffer } from './pairing'
import { isTailnetIPv4Address } from './tailnet-address'
export type RemotePairingEndpointKind = 'loopback' | 'tailscale' | 'lan' | 'public' | 'custom'
export type ParsedHostAccessLink = {
pairing: PairingOffer
displayEndpoint: string
endpointKind: RemotePairingEndpointKind
}
export type HostAccessLinkErrorKind =
| 'invalid-input'
| 'mobile-only'
| 'invalid-destination'
| 'unsupported-destination'
| 'non-connectable-destination'
export type ParseHostAccessLinkResult =
| { ok: true; value: ParsedHostAccessLink }
| { ok: false; kind: HostAccessLinkErrorKind; message: string }
const LOOPBACK_HOSTS = new Set([
'localhost',
'localhost.localdomain',
'localhost6',
'localhost6.localdomain6',
'ip6-localhost',
'ip6-loopback',
'127.0.0.1',
'::1'
])
function isPrivateIPv4Address(hostname: string): boolean {
const octets = hostname.split('.').map(Number)
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) {
return false
}
return (
octets[0] === 10 ||
(octets[0] === 172 && octets[1]! >= 16 && octets[1]! <= 31) ||
(octets[0] === 192 && octets[1] === 168)
)
}
function isPrivateIPv6Address(hostname: string): boolean {
const firstHextet = Number.parseInt(hostname.split(':')[0] ?? '', 16)
return (
Number.isInteger(firstHextet) &&
((firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xffc0) === 0xfe80)
)
}
function getEmbeddedIPv4Address(hostname: string): string | null {
const match = hostname.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i)
if (!match) {
return null
}
const high = Number.parseInt(match[1]!, 16)
const low = Number.parseInt(match[2]!, 16)
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`
}
export function classifyRemotePairingHostname(hostname: string): RemotePairingEndpointKind {
const normalized = hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '')
const embeddedIPv4 = getEmbeddedIPv4Address(normalized)
if (embeddedIPv4) {
return classifyRemotePairingHostname(embeddedIPv4)
}
if (
LOOPBACK_HOSTS.has(normalized) ||
normalized.endsWith('.localhost') ||
normalized.startsWith('127.')
) {
return 'loopback'
}
if (isTailnetIPv4Address(normalized)) {
return 'tailscale'
}
if (isPrivateIPv4Address(normalized) || isPrivateIPv6Address(normalized)) {
return 'lan'
}
return normalized.includes('.') || normalized.includes(':') ? 'public' : 'custom'
}
export function parseHostAccessLink(input: string): ParseHostAccessLinkResult {
const pairing = parsePairingCode(input)
if (!pairing) {
return {
ok: false,
kind: 'invalid-input',
message: 'Enter an Orca access link or bare pairing code.'
}
}
if (pairing.scope === 'mobile') {
return {
ok: false,
kind: 'mobile-only',
message: 'This link grants mobile-only access. Generate a link for another Orca client.'
}
}
let endpoint: URL
try {
endpoint = new URL(pairing.endpoint)
} catch {
return {
ok: false,
kind: 'invalid-destination',
message: 'This access link contains an invalid destination.'
}
}
if (
(endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') ||
!endpoint.hostname ||
endpoint.hash !== ''
) {
return {
ok: false,
kind: 'unsupported-destination',
message: 'This access link contains an unsupported destination.'
}
}
const normalizedHostname = endpoint.hostname.toLowerCase().replace(/^\[|\]$/g, '')
if (
normalizedHostname === '0.0.0.0' ||
normalizedHostname === '::' ||
getEmbeddedIPv4Address(normalizedHostname) === '0.0.0.0' ||
endpoint.port === '0'
) {
return {
ok: false,
kind: 'non-connectable-destination',
message: 'This access link contains a non-connectable destination.'
}
}
return {
ok: true,
value: {
pairing,
displayEndpoint: endpoint.host,
endpointKind: classifyRemotePairingHostname(endpoint.hostname)
}
}
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version'
import { verifyRemotePairingRuntimeStatus } from './remote-pairing-verification'
function runtimeStatus(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
runtimeId: 'runtime-a',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
...overrides
}
}
describe('verifyRemotePairingRuntimeStatus', () => {
it('rejects malformed status responses', () => {
expect(verifyRemotePairingRuntimeStatus(null)).toMatchObject({
ok: false,
kind: 'connection-interrupted'
})
expect(
verifyRemotePairingRuntimeStatus(
runtimeStatus({
runtimeProtocolVersion: Number.NaN
})
)
).toMatchObject({ ok: false, kind: 'connection-interrupted' })
expect(
verifyRemotePairingRuntimeStatus({ runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION })
).toMatchObject({ ok: false, kind: 'connection-interrupted' })
expect(
verifyRemotePairingRuntimeStatus(runtimeStatus({ capabilities: 'runtime.status.compat.v1' }))
).toMatchObject({ ok: false, kind: 'connection-interrupted' })
expect(verifyRemotePairingRuntimeStatus(runtimeStatus({ deviceScope: 'admin' }))).toMatchObject(
{
ok: false,
kind: 'connection-interrupted'
}
)
})
it('rejects mobile-only access grants', () => {
expect(
verifyRemotePairingRuntimeStatus(
runtimeStatus({
deviceScope: 'mobile'
})
)
).toMatchObject({ ok: false, kind: 'access-link-invalid' })
})
it('rejects incompatible hosts', () => {
expect(
verifyRemotePairingRuntimeStatus(
runtimeStatus({
runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1
})
)
).toMatchObject({ ok: false, kind: 'protocol-incompatible' })
})
it('accepts a compatible runtime status', () => {
expect(verifyRemotePairingRuntimeStatus(runtimeStatus())).toMatchObject({ ok: true })
})
})
+114
View File
@@ -0,0 +1,114 @@
import { evaluateRuntimeCompat } from './protocol-compat'
import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version'
import type { PublicKnownRuntimeEnvironment } from './runtime-environments'
import type { RuntimeStatus } from './runtime-types'
export type RemotePairingFailureKind =
| 'host-unreachable'
| 'host-identity-mismatch'
| 'access-link-invalid'
| 'protocol-incompatible'
| 'connection-interrupted'
| 'environment-save-failed'
export type RemotePairingFailure = {
ok: false
kind: RemotePairingFailureKind
message: string
}
export type VerifyAndAddRuntimeEnvironmentResult =
| {
ok: true
environment: PublicKnownRuntimeEnvironment
runtimeStatus: RuntimeStatus
}
| RemotePairingFailure
const RUNTIME_GRAPH_STATUSES = new Set(['ready', 'reloading', 'unavailable'])
function isNonNegativeSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && Number(value) >= 0
}
function hasValidRuntimeStatusShape(status: Record<string, unknown>): boolean {
return (
typeof status.runtimeId === 'string' &&
status.runtimeId.length > 0 &&
isNonNegativeSafeInteger(status.rendererGraphEpoch) &&
typeof status.graphStatus === 'string' &&
RUNTIME_GRAPH_STATUSES.has(status.graphStatus) &&
(status.authoritativeWindowId === null ||
isNonNegativeSafeInteger(status.authoritativeWindowId)) &&
isNonNegativeSafeInteger(status.liveTabCount) &&
isNonNegativeSafeInteger(status.liveLeafCount) &&
(status.deviceScope === undefined ||
status.deviceScope === 'mobile' ||
status.deviceScope === 'runtime') &&
(status.capabilities === undefined ||
(Array.isArray(status.capabilities) &&
status.capabilities.every((capability) => typeof capability === 'string')))
)
}
export function verifyRemotePairingRuntimeStatus(
value: unknown
): { ok: true; runtimeStatus: RuntimeStatus } | RemotePairingFailure {
if (typeof value !== 'object' || value === null) {
return {
ok: false,
kind: 'connection-interrupted',
message: 'The remote host returned an invalid status response.'
}
}
const status = value as Partial<RuntimeStatus> & Record<string, unknown>
if (status.deviceScope === 'mobile') {
return {
ok: false,
kind: 'access-link-invalid',
message: 'This link grants mobile-only access. Generate a link for another Orca client.'
}
}
const versionFields = [
status.runtimeProtocolVersion,
status.protocolVersion,
status.minCompatibleRuntimeClientVersion,
status.minCompatibleMobileVersion
]
if (
versionFields.some(
(version) => version !== undefined && (!Number.isSafeInteger(version) || Number(version) < 0)
)
) {
return {
ok: false,
kind: 'connection-interrupted',
message: 'The remote host returned an invalid protocol version.'
}
}
const compatibility = evaluateRuntimeCompat({
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
serverMinCompatibleClientProtocolVersion:
status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
})
if (compatibility.kind === 'blocked') {
return {
ok: false,
kind: 'protocol-incompatible',
message:
compatibility.reason === 'client-too-old'
? 'Update this Orca client before adding the remote host.'
: 'Update Orca on the remote host before adding it.'
}
}
if (!hasValidRuntimeStatusShape(status)) {
return {
ok: false,
kind: 'connection-interrupted',
message: 'The remote host returned an invalid status response.'
}
}
return { ok: true, runtimeStatus: value as RuntimeStatus }
}
+14 -1
View File
@@ -1,3 +1,5 @@
export type RemoteRuntimePairingStage = 'connect' | 'host-identity' | 'access-grant' | 'runtime'
/**
* Error type for the remote-runtime client, split out from
* `remote-runtime-client.ts` so type-only consumers can reference it without
@@ -7,10 +9,21 @@
*/
export class RemoteRuntimeClientError extends Error {
readonly code: string
readonly pairingStage?: RemoteRuntimePairingStage
readonly closeCode?: number
constructor(code: string, message: string) {
constructor(
code: string,
message: string,
details?: {
pairingStage?: RemoteRuntimePairingStage
closeCode?: number
}
) {
super(message)
this.name = 'RemoteRuntimeClientError'
this.code = code
this.pairingStage = details?.pairingStage
this.closeCode = details?.closeCode
}
}
+51
View File
@@ -197,6 +197,28 @@ describe('sendRemoteRuntimeRequest', () => {
)
})
it('classifies a non-Orca handshake as a host identity mismatch', async () => {
const server = await createInvalidHandshakeServer()
await expect(
sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000)
).rejects.toMatchObject({
code: 'invalid_runtime_response',
pairingStage: 'host-identity'
})
})
it('classifies an undecryptable post-auth frame as a runtime failure', async () => {
const server = await createOneShotServer({ sendUndecryptableResponse: true })
await expect(
sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000)
).rejects.toMatchObject({
code: 'invalid_runtime_response',
pairingStage: 'runtime'
})
})
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
const server = await createOneShotServer()
@@ -425,10 +447,35 @@ async function createClosingServer(
return { pairing }
}
async function createInvalidHandshakeServer(): Promise<{ pairing: PairingOffer }> {
const serverKeyPair = generateKeyPair()
const wss = new WebSocketServer({ port: 0 })
servers.push(wss)
wss.on('connection', (ws) => {
ws.once('message', () => ws.send(JSON.stringify({ type: 'not_orca' })))
})
await new Promise<void>((resolve) => wss.once('listening', resolve))
const address = wss.address() as AddressInfo
const pairing = parsePairingCode(
encodePairingOffer({
v: 2,
endpoint: `ws://127.0.0.1:${address.port}`,
deviceToken: 'device-token',
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey)
})
)
if (!pairing) {
throw new Error('Failed to create test pairing')
}
return { pairing }
}
async function createOneShotServer(
options: {
response?: (requestId: string) => unknown
onRequest?: (request: Record<string, unknown>) => void
sendUndecryptableResponse?: boolean
} = {}
): Promise<{ pairing: PairingOffer }> {
const serverKeyPair = generateKeyPair()
@@ -466,6 +513,10 @@ async function createOneShotServer(
const request = JSON.parse(plaintext) as { id: string } & Record<string, unknown>
options.onRequest?.(request)
if (options.sendUndecryptableResponse) {
ws.send('not-an-encrypted-frame')
return
}
const key = sharedKey
const keepalive = setInterval(() => {
sendEncrypted(ws, key, { _keepalive: true })
+37 -12
View File
@@ -121,6 +121,12 @@ export async function sendRemoteRuntimeRequest<TResult>(
let state: HandshakeState = 'awaiting_ready'
let settled = false
let ws: WebSocket | null = null
const getPairingStage = (): 'connect' | 'host-identity' | 'runtime' =>
state === 'awaiting_ready'
? 'connect'
: state === 'awaiting_authenticated'
? 'host-identity'
: 'runtime'
const cleanupSocketListeners = (): void => {
const socket = ws
@@ -145,7 +151,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'runtime_timeout',
'Timed out waiting for the remote Orca runtime to respond.'
'Timed out waiting for the remote Orca runtime to respond.',
{ pairingStage: getPairingStage() }
)
})
}
@@ -210,7 +217,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'remote_runtime_unavailable',
'Could not connect to the remote Orca runtime.'
'Could not connect to the remote Orca runtime.',
{ pairingStage: getPairingStage() }
)
})
}
@@ -221,7 +229,11 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'remote_runtime_unavailable',
formatRemoteRuntimeCloseMessage(code, reason)
formatRemoteRuntimeCloseMessage(code, reason),
{
pairingStage: getPairingStage(),
closeCode: code
}
)
})
}
@@ -236,7 +248,10 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime returned an unexpected binary frame.'
'Remote Orca runtime returned an unexpected binary frame.',
{
pairingStage: state === 'awaiting_ready' ? 'host-identity' : getPairingStage()
}
)
})
return
@@ -254,7 +269,10 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime returned an undecryptable frame.'
'Remote Orca runtime returned an undecryptable frame.',
{
pairingStage: state === 'awaiting_authenticated' ? 'host-identity' : getPairingStage()
}
)
})
return
@@ -282,7 +300,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime returned an invalid E2EE handshake frame.'
'Remote Orca runtime returned an invalid E2EE handshake frame.',
{ pairingStage: 'host-identity' }
)
})
return
@@ -296,7 +315,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime returned an unexpected E2EE handshake frame.'
'Remote Orca runtime returned an unexpected E2EE handshake frame.',
{ pairingStage: 'host-identity' }
)
})
return
@@ -314,7 +334,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime returned an invalid E2EE auth frame.'
'Remote Orca runtime returned an invalid E2EE auth frame.',
{ pairingStage: 'host-identity' }
)
})
return
@@ -331,7 +352,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
code,
'Remote Orca runtime rejected the pairing token.'
'Remote Orca runtime rejected the pairing token.',
{ pairingStage: code === 'unauthorized' ? 'access-grant' : 'host-identity' }
)
})
return
@@ -361,7 +383,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime returned an invalid response frame.'
'Remote Orca runtime returned an invalid response frame.',
{ pairingStage: 'runtime' }
)
})
return
@@ -376,7 +399,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime returned an invalid response frame.'
'Remote Orca runtime returned an invalid response frame.',
{ pairingStage: 'runtime' }
)
})
return
@@ -387,7 +411,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
ok: false,
error: new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime returned a mismatched response id.'
'Remote Orca runtime returned a mismatched response id.',
{ pairingStage: 'runtime' }
)
})
return
@@ -87,6 +87,29 @@ describe('runtime environment store', () => {
]).toEqual([101, 102, 200])
})
it('keeps SSH-tunnel metadata only while the pairing endpoint is loopback', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-'))
tempDirs.push(userDataPath)
const environment = addEnvironmentFromPairingCode(userDataPath, {
name: 'tunneled box',
pairingCode: pairingCode(),
connectionDependency: 'ssh-tunnel'
})
expect(environment.connectionDependency).toBe('ssh-tunnel')
const updated = updateEnvironmentFromPairingCode(userDataPath, environment.id, {
pairingCode: pairingCode('ws://192.0.2.10:6768')
})
expect(updated).not.toHaveProperty('connectionDependency')
const direct = addEnvironmentFromPairingCode(userDataPath, {
name: 'direct box',
pairingCode: pairingCode('ws://192.0.2.11:6768'),
connectionDependency: 'ssh-tunnel'
})
expect(direct).not.toHaveProperty('connectionDependency')
})
it('throttles lastUsedAt writes so it does not rewrite the store on every runtime call', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-'))
tempDirs.push(userDataPath)
+29 -3
View File
@@ -4,6 +4,7 @@ import { join } from 'node:path'
import { JsonStringifyByteLimitError } from './node-bounded-json-stringify'
import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader'
import { parsePairingCode, type PairingOffer } from './pairing'
import { classifyRemotePairingHostname } from './remote-pairing-address'
import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file'
import { hardenExistingSecureFile } from './secure-file'
import {
@@ -41,7 +42,13 @@ export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[
export function addEnvironmentFromPairingCode(
userDataPath: string,
args: { name: string; pairingCode: string; now?: number; source?: RuntimeEnvironmentSource }
args: {
name: string
pairingCode: string
now?: number
source?: RuntimeEnvironmentSource
connectionDependency?: 'ssh-tunnel'
}
): KnownRuntimeEnvironment {
const offer = parsePairingCode(args.pairingCode)
if (!offer) {
@@ -65,7 +72,8 @@ export function addEnvironmentFromPairingCode(
now,
offer,
runtimeId: null,
...(args.source ? { source: args.source } : {})
...(args.source ? { source: args.source } : {}),
...getPairingConnectionDependency(args.connectionDependency, offer)
})
const next = {
version: 1 as const,
@@ -110,7 +118,8 @@ export function updateEnvironmentFromPairingCode(
now: existing.createdAt,
offer,
runtimeId: existing.runtimeId,
...(existing.source ? { source: existing.source } : {})
...(existing.source ? { source: existing.source } : {}),
...getPairingConnectionDependency(existing.connectionDependency, offer)
})
const next = {
...environment,
@@ -128,6 +137,23 @@ export function updateEnvironmentFromPairingCode(
return next
}
function getPairingConnectionDependency(
dependency: 'ssh-tunnel' | undefined,
offer: PairingOffer
): { connectionDependency?: 'ssh-tunnel' } {
if (!dependency) {
return {}
}
try {
const endpoint = new URL(offer.endpoint)
return classifyRemotePairingHostname(endpoint.hostname) === 'loopback'
? { connectionDependency: dependency }
: {}
} catch {
return {}
}
}
export function resolveEnvironment(
userDataPath: string,
selector: string
+3
View File
@@ -31,6 +31,7 @@ export const KnownRuntimeEnvironmentSchema = z.object({
lastUsedAt: z.number().finite().nullable(),
runtimeId: z.string().min(1).nullable(),
source: RuntimeEnvironmentSourceSchema.optional(),
connectionDependency: z.literal('ssh-tunnel').optional(),
endpoints: z.array(RuntimeAccessEndpointSchema).min(1),
preferredEndpointId: z.string().min(1)
})
@@ -66,6 +67,7 @@ export function createEnvironmentFromPairingOffer(args: {
offer: PairingOffer
runtimeId?: string | null
source?: RuntimeEnvironmentSource
connectionDependency?: 'ssh-tunnel'
}): KnownRuntimeEnvironment {
const endpointId = `ws-${args.id}`
return KnownRuntimeEnvironmentSchema.parse({
@@ -77,6 +79,7 @@ export function createEnvironmentFromPairingOffer(args: {
lastUsedAt: null,
runtimeId: args.runtimeId ?? null,
...(args.source ? { source: args.source } : {}),
...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}),
endpoints: [
{
id: endpointId,