mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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.`
|
||||
}
|
||||
}
|
||||
@@ -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<
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
'Couldn’t load temporary VM runtimes.'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoadFailed',
|
||||
'Couldn’t 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',
|
||||
'Couldn’t clean up temporary VM runtime.'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast',
|
||||
'Couldn’t 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',
|
||||
'Couldn’t clean up temporary VM runtime.'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast',
|
||||
'Couldn’t 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 "Linux workstation" 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 host’s 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 host’s Tailscale or LAN address.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 host’s 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": "Couldn’t load Cloud VM runtimes.",
|
||||
"cloudVmCleanupFailedToast": "Couldn’t 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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user