From a60aa85592b69c4ad9856691c30beaacc472e07b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:31:34 -0700 Subject: [PATCH] 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 --- src/main/daemon/daemon-pty-router-events.ts | 15 + src/main/daemon/daemon-pty-router.ts | 30 +- ...ntime-environment-connectivity-handlers.ts | 11 + .../runtime-environment-handler-channels.ts | 13 + ...untime-environment-pairing-verification.ts | 140 ++++++ src/main/ipc/runtime-environments.test.ts | 187 ++++++- src/main/ipc/runtime-environments.ts | 14 +- src/preload/api-types.ts | 6 + src/preload/index.ts | 7 + .../EphemeralVmRuntimesSection.test.tsx | 4 +- .../settings/EphemeralVmRuntimesSection.tsx | 40 +- .../settings/RuntimeEnvironmentsPane.tsx | 470 +++++++++++------- .../settings/RuntimeHostAccessForm.test.tsx | 84 ++++ .../settings/RuntimeHostAccessForm.tsx | 341 +++++++++++++ .../RuntimePairingGeneratorForm.test.tsx | 68 +++ .../settings/RuntimePairingGeneratorForm.tsx | 363 ++++++++++---- .../RuntimePairingUrlGenerator.test.tsx | 70 +++ .../settings/RuntimePairingUrlGenerator.tsx | 108 ++-- .../settings/runtime-pairing-link-state.ts | 64 +++ .../sidebar/AddRemoteHostDialog.tsx | 62 ++- .../sidebar/AddRemoteHostFields.test.tsx | 38 ++ .../sidebar/AddRemoteHostFields.tsx | 88 +++- src/renderer/src/i18n/locales/en.json | 105 +++- src/renderer/src/i18n/locales/es.json | 102 +++- src/renderer/src/i18n/locales/ja.json | 102 +++- src/renderer/src/i18n/locales/ko.json | 102 +++- src/renderer/src/i18n/locales/zh.json | 168 +++++-- .../src/lib/i18n-jsx-spacing-guard.test.ts | 5 - src/renderer/src/lib/remote-pairing-copy.ts | 91 ++++ src/renderer/src/web/web-preload-api.test.ts | 183 +++++++ src/renderer/src/web/web-preload-api.ts | 104 ++++ .../src/web/web-runtime-client-error.ts | 25 + src/renderer/src/web/web-runtime-client.ts | 13 +- .../src/web/web-runtime-environment.ts | 2 + .../network/server-share-address.test.ts | 25 +- src/shared/network/server-share-address.ts | 38 +- src/shared/pairing.test.ts | 15 +- src/shared/pairing.ts | 7 +- src/shared/remote-pairing-address.test.ts | 99 ++++ src/shared/remote-pairing-address.ts | 147 ++++++ .../remote-pairing-verification.test.ts | 68 +++ src/shared/remote-pairing-verification.ts | 114 +++++ src/shared/remote-runtime-client-error.ts | 15 +- src/shared/remote-runtime-client.test.ts | 51 ++ src/shared/remote-runtime-client.ts | 49 +- src/shared/runtime-environment-store.test.ts | 23 + src/shared/runtime-environment-store.ts | 32 +- src/shared/runtime-environments.ts | 3 + 48 files changed, 3389 insertions(+), 522 deletions(-) create mode 100644 src/main/daemon/daemon-pty-router-events.ts create mode 100644 src/main/ipc/runtime-environment-handler-channels.ts create mode 100644 src/main/ipc/runtime-environment-pairing-verification.ts create mode 100644 src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx create mode 100644 src/renderer/src/components/settings/RuntimeHostAccessForm.tsx create mode 100644 src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx create mode 100644 src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx create mode 100644 src/renderer/src/components/settings/runtime-pairing-link-state.ts create mode 100644 src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx create mode 100644 src/renderer/src/lib/remote-pairing-copy.ts create mode 100644 src/renderer/src/web/web-runtime-client-error.ts create mode 100644 src/shared/remote-pairing-address.test.ts create mode 100644 src/shared/remote-pairing-address.ts create mode 100644 src/shared/remote-pairing-verification.test.ts create mode 100644 src/shared/remote-pairing-verification.ts diff --git a/src/main/daemon/daemon-pty-router-events.ts b/src/main/daemon/daemon-pty-router-events.ts new file mode 100644 index 00000000000..41c1d560286 --- /dev/null +++ b/src/main/daemon/daemon-pty-router-events.ts @@ -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 +} diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 315c1d0bb31..b90086ab532 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -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() 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) diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 7f2f49e84d3..28d303d28a6 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -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)) ) diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts new file mode 100644 index 00000000000..2c74a79c9f4 --- /dev/null +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -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 diff --git a/src/main/ipc/runtime-environment-pairing-verification.ts b/src/main/ipc/runtime-environment-pairing-verification.ts new file mode 100644 index 00000000000..e39d727d1b0 --- /dev/null +++ b/src/main/ipc/runtime-environment-pairing-verification.ts @@ -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 { + 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( + 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 + 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.` + } +} diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 83326570582..d0101ee3968 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -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 { + return { + runtimeId: 'runtime-a', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + protocolVersion: 999_999 + } +} + function handler( channel: string ): (_event: unknown, args: TArgs) => TResult | Promise { @@ -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< diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index e01a5d9ae63..6bf71edd634 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -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 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e0f86d43761..d539ba6fc67 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 resolve: (args: { selector: string }) => Promise remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> disconnect: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 63db42aeddd..00c8402211b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => + ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), resolve: (args: { selector: string }): Promise => ipcRenderer.invoke('runtimeEnvironments:resolve', args), remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => diff --git a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx index 401a88277f5..9bd5b9a293c 100644 --- a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx @@ -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.') ) }) diff --git a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx index 876730fc5d2..fc7d88e5189 100644 --- a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx @@ -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 {
{translate( - 'auto.components.settings.EphemeralVmRuntimesSection.title', - 'Temporary VM runtimes' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmTitle', + 'Cloud VM runtimes' )}

@@ -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 {

{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.' )}
) : ( diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index d22ce1fadfd..9ada390c784 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -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(null) const [pendingRemove, setPendingRemove] = useState(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('connect') const [switchError, setSwitchError] = useState(null) const [removeError, setRemoveError] = useState(null) const [name, setName] = useState('') const [pairingCode, setPairingCode] = useState('') + const [addServerFailure, setAddServerFailure] = useState(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 => { - 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 => { if (mountedRef.current) { - setEnvironments(visibleEnvironments) - setDetailsByEnvironmentId((current) => { - const next: Record = {} - 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(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 = {} + 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(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 => { + const addEnvironment = async (allowLoopback: boolean): Promise => { 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 ( -
+
+ {( + [ + [ + '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]) => ( + + ))} +
+ +
{addServerFormOpen ? ( -
{ - event.preventDefault() - void addEnvironment() + { + setPairingCode(value) + setAddServerFailure(null) }} - > -
-
- - setName(event.target.value)} - placeholder={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.e038625857', - 'Dev box' - )} - className="h-8 text-xs" - autoFocus - /> -
-
- - setPairingCode(event.target.value)} - placeholder={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.c3d772c514', - 'orca://pair?code=...' - )} - className="h-8 min-w-0 font-mono text-xs" - /> -

- {translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')}{' '} - - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.960e901ae4', - 'orca serve --pairing-address ' - )} - {' '} - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.55fcc964cd', - 'on the server and paste the printed pairing URL.' - )} -

-
-
-
- - -
- + onCancel={closeAddServerForm} + onSubmit={(allowLoopback) => void addEnvironment(allowLoopback)} + /> ) : null}
@@ -936,12 +978,17 @@ export function RuntimeEnvironmentsPane({ ) : null}

- {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)}

{detailsDescription ? (

- +
+ +
-
+
+ +
+ + ) +} diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx new file mode 100644 index 00000000000..7515b204883 --- /dev/null +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx @@ -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( + + + + ) +} + +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') + }) +}) diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx index 5557f61765f..1c5176a999c 100644 --- a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx @@ -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 ( <>
-
-
-

- {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.' - )} -

+ )} + {staleGeneratedLink && selectedAddress !== '' ? ( +
+ {translate( + 'auto.components.settings.RuntimePairingUrlGenerator.staleAddress', + 'The connection address changed. Generate a new link for {{address}}.', + { address: selectedAddress } + )} +
+ ) : null}
- {webClientUrl ? ( + {generatedIsCurrent && webClientUrl ? ( onCopy('web', webClientUrl)} /> - ) : runtimePairingUrl ? ( + ) : generatedIsCurrent && runtimePairingUrl ? ( ) : null} - {runtimePairingUrl ? ( + {generatedIsCurrent && runtimePairingUrl ? ( ({ + 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 }) => ( +
{props.selectedAddress}
+ ) +})) + +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() + 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') + ) + }) +}) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index 590081874ca..7ab34224727 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -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(runtimePairingLinkCache.intent) + const [generatedAddress, setGeneratedAddress] = useState( + runtimePairingLinkCache.generatedAddress + ) const [runtimePairingUrl, setRuntimePairingUrl] = useState( - runtimePairingUrlCache.runtimePairingUrl + runtimePairingLinkCache.runtimePairingUrl ) const [webClientUrl, setWebClientUrl] = useState( - runtimePairingUrlCache.webClientUrl + runtimePairingLinkCache.webClientUrl ) const [runtimePairingDeviceId, setRuntimePairingDeviceId] = useState( - runtimePairingUrlCache.runtimePairingDeviceId + runtimePairingLinkCache.runtimePairingDeviceId ) const [runtimeAccessGrants, setRuntimeAccessGrants] = useState([]) 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 => { + 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 ? ( void loadNetworkInterfaces({ showToastOnError: true })} onGenerate={() => void generateRuntimePairingUrl()} diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.ts new file mode 100644 index 00000000000..0b3a1230fb9 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.ts @@ -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 +} diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index 1c31dfebe79..5e2527bf0d6 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -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(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({ { + 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...') diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx new file mode 100644 index 00000000000..631eb7b3025 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx @@ -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( + + ) + + expect(markup).toContain('aria-invalid="true"') + expect(markup).toContain('aria-describedby="add-server-loopback-blocked"') + expect(markup).toContain('id="add-server-loopback-blocked"') + }) +}) diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx index 370e847ebee..60a940dd0e0 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx @@ -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 (
onPairingCodeChange(event.target.value)} @@ -171,20 +194,63 @@ export function RemoteServerFields({ )} className="font-mono" /> -

- {translate('auto.components.sidebar.AddRemoteHostDialog.pairingHelpPrefix', 'Run')}{' '} - - {translate( - 'auto.components.sidebar.AddRemoteHostDialog.pairingCommand', - 'orca serve --pairing-address ' - )} - {' '} +

{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.' )}

+ {inputError ? ( + + ) : null}
+ {parsedLink.ok ? ( +
+
+ {translate( + 'auto.components.sidebar.AddRemoteHostDialog.linkDestination', + 'Link destination' + )} + + {translateRemotePairingEndpointKind(parsedLink.value.endpointKind)} + +
+
{parsedLink.value.displayEndpoint}
+ {parsedLink.value.endpointKind === 'loopback' ? ( + + ) : null} +
+ ) : null} + {loopbackBlocked ? ( + + ) : null} ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e6f91319871..c9f12dc3036 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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 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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b925b8c47c3..21c87820dbd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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 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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3e371187419..90b882eaa4d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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 を実行してください。", + "cancel": "キャンセル", + "addWithTunnel": "トンネルを使ってホストを追加", + "addHost": "ホストを追加", + "connectionDetails": "接続の詳細", + "endpointKind": "接続先の種類", + "networkConnection": "ネットワーク接続", + "notAttempted": "未試行" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5310100e4be..d866a557f6a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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 를 실행하세요.", + "cancel": "취소", + "addWithTunnel": "터널을 사용해 호스트 추가", + "addHost": "호스트 추가", + "connectionDetails": "연결 세부 정보", + "endpointKind": "대상 유형", + "networkConnection": "네트워크 연결", + "notAttempted": "시도하지 않음" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3a948e98e79..7b3709fcc3e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts index f2dde24571e..1e2438dbce5 100644 --- a/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts +++ b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts @@ -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', diff --git a/src/renderer/src/lib/remote-pairing-copy.ts b/src/renderer/src/lib/remote-pairing-copy.ts new file mode 100644 index 00000000000..72d74ad494b --- /dev/null +++ b/src/renderer/src/lib/remote-pairing-copy.ts @@ -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' } + ) + } +} diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index a8636b50ea6..6b5b3a093e3 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -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> { + 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> { + 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> { + 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', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d8e3ef432e2..5a262aed62c 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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['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 + 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 }) => { diff --git a/src/renderer/src/web/web-runtime-client-error.ts b/src/renderer/src/web/web-runtime-client-error.ts new file mode 100644 index 00000000000..c09daa02bdc --- /dev/null +++ b/src/renderer/src/web/web-runtime-client-error.ts @@ -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' + ) +} diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 9f61eefe37f..12507f6225f 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -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) diff --git a/src/renderer/src/web/web-runtime-environment.ts b/src/renderer/src/web/web-runtime-environment.ts index 7aa48f5711d..4f97c416960 100644 --- a/src/renderer/src/web/web-runtime-environment.ts +++ b/src/renderer/src/web/web-runtime-environment.ts @@ -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: [ diff --git a/src/shared/network/server-share-address.test.ts b/src/shared/network/server-share-address.test.ts index 933ef09ff75..e7be14d14d3 100644 --- a/src/shared/network/server-share-address.test.ts +++ b/src/shared/network/server-share-address.test.ts @@ -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', () => { diff --git a/src/shared/network/server-share-address.ts b/src/shared/network/server-share-address.ts index 1afe5f728b7..35b84b9fb1d 100644 --- a/src/shared/network/server-share-address.ts +++ b/src/shared/network/server-share-address.ts @@ -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 } } diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts index ff9eaed0c81..1d1879587be 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -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) diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 00a3e7e0fdd..3c8335162a7 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -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)) } diff --git a/src/shared/remote-pairing-address.test.ts b/src/shared/remote-pairing-address.test.ts new file mode 100644 index 00000000000..0b38be5a985 --- /dev/null +++ b/src/shared/remote-pairing-address.test.ts @@ -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' }) + }) +}) diff --git a/src/shared/remote-pairing-address.ts b/src/shared/remote-pairing-address.ts new file mode 100644 index 00000000000..50b3241272d --- /dev/null +++ b/src/shared/remote-pairing-address.ts @@ -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) + } + } +} diff --git a/src/shared/remote-pairing-verification.test.ts b/src/shared/remote-pairing-verification.test.ts new file mode 100644 index 00000000000..8fae1dfb82d --- /dev/null +++ b/src/shared/remote-pairing-verification.test.ts @@ -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 = {}): Record { + 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 }) + }) +}) diff --git a/src/shared/remote-pairing-verification.ts b/src/shared/remote-pairing-verification.ts new file mode 100644 index 00000000000..02d58b97bed --- /dev/null +++ b/src/shared/remote-pairing-verification.ts @@ -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): 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 & Record + 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 } +} diff --git a/src/shared/remote-runtime-client-error.ts b/src/shared/remote-runtime-client-error.ts index b6429620c04..487a1108b87 100644 --- a/src/shared/remote-runtime-client-error.ts +++ b/src/shared/remote-runtime-client-error.ts @@ -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 } } diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index d16b3e34807..43a2a982d73 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -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((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) => 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 options.onRequest?.(request) + if (options.sendUndecryptableResponse) { + ws.send('not-an-encrypted-frame') + return + } const key = sharedKey const keepalive = setInterval(() => { sendEncrypted(ws, key, { _keepalive: true }) diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index dafa2261feb..8cc5c14046e 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -121,6 +121,12 @@ export async function sendRemoteRuntimeRequest( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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 diff --git a/src/shared/runtime-environment-store.test.ts b/src/shared/runtime-environment-store.test.ts index f58093337fa..3e34f5846b2 100644 --- a/src/shared/runtime-environment-store.test.ts +++ b/src/shared/runtime-environment-store.test.ts @@ -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) diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 4211eb18da8..85f90f8bef6 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -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 diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index dd3e3b357ce..131414631c2 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -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,