fix(pairing): adding a server you already saved updates it instead of saving it twice (#31080)

* fix(pairing): pairing a saved server again refreshes its entry instead of saving it twice

* fix(pairing): only Settings refreshes a saved server in place; other add paths refuse the duplicate

* fix(pairing): a same-key refresh keeps the server's workspaces and tabs; raw add refuses; match any endpoint key

* fix(pairing): refuse a code for a hidden saved server; one choke point publishes verified status only for the pairing still saved

* refactor(pairing): verified-pairing publish in its own module; an unreadable store skips the stamp instead of failing a saved pairing

* fix(pairing): match a host key however its Base64 is spelled

* fix(pairing): a refresh keeps the saved key spelling so the host fingerprint stays put
This commit is contained in:
Neil
2026-10-20 03:09:31 -07:00
committed by GitHub
parent 956a6aadcb
commit 63246eba3a
32 changed files with 828 additions and 120 deletions
+21 -3
View File
@@ -11,12 +11,12 @@ import {
resolveEnvironmentPairingOffer
} from './environments'
function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
function pairingCode(endpoint = 'ws://127.0.0.1:6768', keyFill = 1): string {
return encodePairingOffer({
v: 2,
endpoint,
deviceToken: 'device-token',
publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64')
publicKeyB64: Buffer.from(new Uint8Array(32).fill(keyFill)).toString('base64')
})
}
@@ -71,7 +71,7 @@ describe('CLI runtime environments', () => {
expect(() =>
addEnvironmentFromPairingCode(userDataPath, {
name: 'workstation',
pairingCode: pairingCode('ws://127.0.0.1:2222'),
pairingCode: pairingCode('ws://127.0.0.1:2222', 2),
now: 200
})
).toThrow('A server named "workstation" already exists.')
@@ -81,4 +81,22 @@ describe('CLI runtime environments', () => {
)
expect(listEnvironments(userDataPath)[0]?.id).toBe(first.id)
})
it('refuses saving the same server twice and points at Settings, which can switch its connection', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-'))
addEnvironmentFromPairingCode(userDataPath, {
name: 'workstation',
pairingCode: pairingCode('ws://192.0.2.10:6768'),
now: 100
})
expect(() =>
addEnvironmentFromPairingCode(userDataPath, {
name: 'workstation over relay',
pairingCode: pairingCode('ws://198.51.100.7:6768'),
now: 200
})
).toThrow('already paired as "workstation"')
expect(listEnvironments(userDataPath)).toHaveLength(1)
})
})
@@ -21,9 +21,9 @@ import type { Store } from '../../persistence'
import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup'
import { readSettingsWithRuntimeEnvironmentPreference } from './runtime-environment-preference'
import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification'
import { publishVerifiedPairing } from './runtime-environment-verified-pairing-publish'
import {
closeRemoteRuntimeRequestConnection,
getRuntimeEnvironmentStatusOwner,
getRuntimeEnvironmentStatusSnapshots,
retryRemoteRuntimeSharedControlConnectionNow
} from './runtime-environment-request-connections'
@@ -92,6 +92,7 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
_event,
args: { name: string; pairingCode: string }
): { environment: PublicKnownRuntimeEnvironment } => {
// Why refuse, not refresh: nothing here proves the code works before it would replace one that does.
const environment = addEnvironmentFromPairingCode(getUserDataPath(), args)
clearRuntimeEnvironmentManualDisconnect(environment.id)
return { environment: redactRuntimeEnvironment(environment) }
@@ -102,13 +103,11 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
async (_event, args: { name: string; pairingCode: string; allowLoopback?: boolean }) => {
const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args)
if (result.ok) {
clearRuntimeEnvironmentManualDisconnect(result.environment.id)
getRuntimeEnvironmentStatusOwner(getUserDataPath(), result.environment.id).acceptVerified({
id: 'status.get',
ok: true,
result: result.runtimeStatus,
_meta: { runtimeId: result.runtimeStatus.runtimeId }
})
await publishVerifiedPairing(
getUserDataPath(),
result,
result.updatedExisting ? { invalidateTransport } : null
)
}
return result
}
@@ -1,6 +1,6 @@
import { pairedRuntimeConnectionFromOffer } from '../../../shared/paired-runtime-connection'
import {
addEnvironmentFromPairingCode,
addOrUpdateEnvironmentFromPairingCode,
resolveEnvironment,
RuntimeEnvironmentStoreError,
updateEnvironmentFromPairingCode
@@ -93,10 +93,12 @@ export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
}
const { link, runtimeStatus } = verified
const usesSshTunnel = link.endpointKind === 'loopback' && args.allowLoopback === true
let environment: ReturnType<typeof addEnvironmentFromPairingCode>
let outcome: ReturnType<typeof addOrUpdateEnvironmentFromPairingCode>
try {
environment = addEnvironmentFromPairingCode(userDataPath, {
outcome = addOrUpdateEnvironmentFromPairingCode(userDataPath, {
...args,
// Why: recorded only if the link is for a saved server, which the key match proves it is.
runtimeId: runtimeStatus.runtimeId,
...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {})
})
} catch (error) {
@@ -111,8 +113,9 @@ export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
}
return {
ok: true,
environment: redactRuntimeEnvironment(environment),
runtimeStatus
environment: redactRuntimeEnvironment(outcome.environment),
runtimeStatus,
...(outcome.updatedExisting ? { updatedExisting: true as const } : {})
}
}
@@ -43,7 +43,8 @@ function saveEnvironment(name = 'server') {
v: 2,
endpoint: 'ws://127.0.0.1:59999',
deviceToken: 'test-token',
publicKeyB64: Buffer.alloc(32, 1).toString('base64')
// Why a key per name: one key is one server, which the store refuses to save twice.
publicKeyB64: Buffer.alloc(32, name).toString('base64')
})
})
}
@@ -1,8 +1,7 @@
import { ipcMain } from 'electron'
import type { VerifyAndAddRuntimeEnvironmentResult } from '../../../shared/remote-pairing-verification'
import { verifyAndRepairRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification'
import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections'
import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect'
import { publishVerifiedPairing } from './runtime-environment-verified-pairing-publish'
export function registerRuntimeEnvironmentRepairHandler({
getUserDataPath,
@@ -18,20 +17,9 @@ export function registerRuntimeEnvironmentRepairHandler({
args: { selector: string; pairingCode: string }
): Promise<VerifyAndAddRuntimeEnvironmentResult> => {
const result = await verifyAndRepairRuntimeEnvironmentFromPairingCode(getUserDataPath(), args)
if (!result.ok) {
return result
if (result.ok) {
await publishVerifiedPairing(getUserDataPath(), result, { invalidateTransport })
}
const environmentId = result.environment.id
// Why before invalidating: a manually disconnected server would not get a fresh owner.
clearRuntimeEnvironmentManualDisconnect(environmentId)
// Retires the owner blocked under the revoked pairing; its replacement keys on the new revision.
await invalidateTransport(environmentId)
getRuntimeEnvironmentStatusOwner(getUserDataPath(), environmentId).acceptVerified({
id: 'status.get',
ok: true,
result: result.runtimeStatus,
_meta: { runtimeId: result.runtimeStatus.runtimeId }
})
return result
}
)
@@ -85,11 +85,7 @@ describe('session.tabs tab delta across mixed client versions', () => {
runtime.registerPty(`pty-${index + 1}`, SESSION_WORKTREE_ID)
}
const hostDataPath = mkdtempSync(join(tmpdir(), 'orca-tab-delta-host-'))
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-tab-delta-client-'))
cleanups.push(() => {
rmSync(hostDataPath, { recursive: true, force: true })
rmSync(userDataPath, { recursive: true, force: true })
})
cleanups.push(() => rmSync(hostDataPath, { recursive: true, force: true }))
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath: hostDataPath,
@@ -98,18 +94,31 @@ describe('session.tabs tab delta across mixed client versions', () => {
})
await server.start()
cleanups.push(() => server.stop())
const pair = (name: string): string => {
// Why a profile per desktop: one profile saves a server once, so each desktop needs its own.
const pair = (name: string): { userDataPath: string; id: string } => {
const offer = server.createPairingOffer({ address: '127.0.0.1', name, scope: 'runtime' })
if (!offer.available) {
throw new Error('pairing_unavailable')
}
return addEnvironmentFromPairingCode(userDataPath, { name, pairingCode: offer.pairingUrl }).id
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-tab-delta-client-'))
cleanups.push(() => rmSync(userDataPath, { recursive: true, force: true }))
const { id } = addEnvironmentFromPairingCode(userDataPath, {
name,
pairingCode: offer.pairingUrl
})
return { userDataPath, id }
}
const pairedConnection = (name: string) => {
const desktop = pair(name)
return getPreferredPairedRuntimeConnection(
resolveEnvironment(desktop.userDataPath, desktop.id)
)
}
// A desktop released before the delta: same handshake, minus the one capability.
const legacy = recorder()
const legacySubscription = await subscribeRemoteRuntimeRequest(
getPreferredPairedRuntimeConnection(resolveEnvironment(userDataPath, pair('old-desktop'))),
pairedConnection('old-desktop'),
'session.tabs.subscribe',
{ worktree: `id:${SESSION_WORKTREE_ID}` },
10_000,
@@ -125,7 +134,7 @@ describe('session.tabs tab delta across mixed client versions', () => {
// What a new desktop's socket carries before main decodes it.
const wire = recorder()
const wireSubscription = await subscribeRemoteRuntimeRequest(
getPreferredPairedRuntimeConnection(resolveEnvironment(userDataPath, pair('new-wire'))),
pairedConnection('new-wire'),
'session.tabs.subscribe',
{ worktree: `id:${SESSION_WORKTREE_ID}` },
10_000,
@@ -136,9 +145,10 @@ describe('session.tabs tab delta across mixed client versions', () => {
// This desktop: the real main-process path, which decodes before the window sees a frame.
const current = recorder()
const desktop = pair('new-desktop')
const currentSubscription = await subscribeRuntimeEnvironment(
userDataPath,
pair('new-desktop'),
desktop.userDataPath,
desktop.id,
'session.tabs.subscribe',
{ worktree: `id:${SESSION_WORKTREE_ID}` },
10_000,
@@ -0,0 +1,42 @@
import type { VerifyAndAddRuntimeEnvironmentResult } from '../../../shared/remote-pairing-verification'
import { listEnvironments } from '../../../shared/runtime-environment-store'
import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections'
import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect'
type VerifiedPairing = Extract<VerifyAndAddRuntimeEnvironmentResult, { ok: true }>
function savedPairingRevision(userDataPath: string, environmentId: string): number | undefined {
try {
return listEnvironments(userDataPath).find((entry) => entry.id === environmentId)
?.pairingRevision
} catch {
// Why: the pairing is already saved; an unreadable store only means its status waits for a probe.
return undefined
}
}
/**
* Publishes the status a pairing was verified with, retiring the replaced pairing's owner first when
* `replaced`. One choke point so every save-after-verify path fences against an overlapping save.
*/
export async function publishVerifiedPairing(
userDataPath: string,
result: VerifiedPairing,
replaced: { invalidateTransport: (environmentId: string) => Promise<void> | void } | null
): Promise<void> {
const environmentId = result.environment.id
// Why before invalidating: a manually disconnected server would not get a fresh owner.
clearRuntimeEnvironmentManualDisconnect(environmentId)
// Retires the owner blocked under the replaced pairing; its replacement keys on the new revision.
await replaced?.invalidateTransport(environmentId)
// Why: an overlapping save may have replaced this pairing during the await.
if (savedPairingRevision(userDataPath, environmentId) !== result.environment.pairingRevision) {
return
}
getRuntimeEnvironmentStatusOwner(userDataPath, environmentId).acceptVerified({
id: 'status.get',
ok: true,
result: result.runtimeStatus,
_meta: { runtimeId: result.runtimeStatus.runtimeId }
})
}
@@ -1,5 +1,8 @@
import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status'
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
import {
getRuntimeEnvironmentStatusSnapshots,
resetRuntimeEnvironmentStatusOwners
} from './runtime-environment-request-connections'
import { mkdtempSync, rmSync } from 'node:fs'
import { EventEmitter } from 'node:events'
import { tmpdir } from 'node:os'
@@ -410,7 +413,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
await expect(
verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768')
pairingCode: pairingCode('ws://100.76.32.125:6768', { keyByte: 2 })
})
).resolves.toMatchObject({
ok: false,
@@ -420,6 +423,97 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1)
})
it('refreshes the saved server when a verified link is for it, and retires its old connection', async () => {
registerRuntimeEnvironmentHandlers(store as never)
const saved = 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; updatedExisting?: boolean; environment?: { id: string; name: string } }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
await expect(
verifyAndAdd(null, {
name: 'desk over relay',
pairingCode: pairingCode('ws://192.0.2.10:6768', { deviceToken: 'second-token' })
})
).resolves.toMatchObject({
ok: true,
updatedExisting: true,
environment: { id: saved.id, name: 'desk' }
})
expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1)
expect(environmentStore.resolveEnvironmentPairingOffer(userDataPath, saved.id)).toMatchObject({
endpoint: 'ws://192.0.2.10:6768',
deviceToken: 'second-token'
})
expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(saved.id)
})
it('does not stamp a refresh that a newer one overtook with its stale verification', async () => {
registerRuntimeEnvironmentHandlers(store as never)
const saved = 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' }
})
// An overlapping re-add lands while this one retires the old connection.
let overtaken = false
closeRemoteRuntimeRequestConnectionMock.mockImplementation(() => {
const current = environmentStore.resolveEnvironmentPairingOffer(userDataPath, saved.id)
if (overtaken || current.deviceToken !== 'second-token') {
return
}
overtaken = true
// The newer pairing answers as another process, so only a stale stamp reads runtime-a.
const newer = { ...runtimeStatus(), runtimeId: 'runtime-b' }
for (const mock of [
sendRemoteRuntimeRequestMock,
sendRemoteRuntimeSharedControlRequestMock
]) {
mock.mockResolvedValue({
id: 'status',
ok: true,
result: newer,
_meta: { runtimeId: 'runtime-b' }
})
}
environmentStore.addOrUpdateEnvironmentFromPairingCode(userDataPath, {
name: 'desk',
pairingCode: pairingCode('ws://192.0.2.11:6768', { deviceToken: 'third-token' }),
now: Date.now() + 60_000
})
})
const verifyAndAdd = handler<{ name: string; pairingCode: string }, { ok: boolean }>(
'runtimeEnvironments:verifyAndAddFromPairingCode'
)
await verifyAndAdd(null, {
name: 'desk again',
pairingCode: pairingCode('ws://192.0.2.10:6768', { deviceToken: 'second-token' })
})
expect(overtaken).toBe(true)
const snapshot = getRuntimeEnvironmentStatusSnapshots().find(
(entry) => entry.environmentId === saved.id
)
expect(snapshot).toBeDefined()
expect(['runtime-b', undefined]).toContain(snapshot?.status?.runtimeId)
})
it('requires an explicit Advanced selection before removing the Active Server', async () => {
registerRuntimeEnvironmentHandlers(store as never)
const add = handler<
@@ -179,11 +179,11 @@ describe('runtimeEnvironments:repairFromPairingCode', () => {
it('declines a server another Orca service pairs', async () => {
environmentStore.addEnvironmentFromPairingCode(userDataPath, {
name: 'vm',
pairingCode: pairingCode(ENDPOINT, { deviceToken: 'vm-token' }),
pairingCode: pairingCode(ENDPOINT, { deviceToken: 'vm-token', keyByte: 2 }),
source: 'ephemeral-vm'
})
await expect(
repair(null, { selector: 'vm', pairingCode: pairingCode(ENDPOINT) })
repair(null, { selector: 'vm', pairingCode: pairingCode(ENDPOINT, { keyByte: 2 }) })
).resolves.toMatchObject({ ok: false, kind: 'environment-save-failed' })
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
})
@@ -120,7 +120,6 @@ export function RuntimeEnvironmentsPane({
addEnvironment,
removeEnvironment
} = useRuntimeEnvironmentMutationActions({
environments,
settings,
allowLocalRuntime,
mountedRef,
@@ -0,0 +1,111 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
const { toastSuccess, toastError } = vi.hoisted(() => ({
toastSuccess: vi.fn(),
toastError: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { success: toastSuccess, error: toastError } }))
import { useRuntimeEnvironmentMutationActions } from './use-runtime-environment-mutation-actions'
const verifyAndAddFromPairingCode = vi.fn()
const remove = vi.fn()
const saved: PublicKnownRuntimeEnvironment = {
id: 'env-studio',
name: 'Studio',
createdAt: 1,
updatedAt: 2,
lastUsedAt: null,
runtimeId: 'runtime-a',
endpoints: [{ id: 'ws-env-studio', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }],
preferredEndpointId: 'ws-env-studio'
}
beforeEach(() => {
toastSuccess.mockReset()
toastError.mockReset()
verifyAndAddFromPairingCode.mockReset()
remove.mockReset()
Object.defineProperty(window, 'api', {
configurable: true,
value: { runtimeEnvironments: { verifyAndAddFromPairingCode, remove } }
})
})
afterEach(() => cleanup())
function renderActions(connectEnvironment: () => Promise<boolean>, name = 'Studio over relay') {
const view = renderHook(() =>
useRuntimeEnvironmentMutationActions({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: addEnvironment reads no settings.
settings: {} as GlobalSettings,
allowLocalRuntime: false,
mountedRef: { current: true },
setAddServerFormOpen: vi.fn(),
loadEnvironments: vi.fn(async () => {}),
connectEnvironment
})
)
act(() => {
view.result.current.setName(name)
view.result.current.setPairingCode('orca://pair#code')
})
return view
}
it('keeps a refreshed saved server when the follow-up connect fails, and says which one it was', async () => {
verifyAndAddFromPairingCode.mockResolvedValue({
ok: true,
environment: saved,
runtimeStatus: { runtimeId: 'runtime-a' },
updatedExisting: true
})
const view = renderActions(async () => false)
await act(async () => {
await view.result.current.addEnvironment(false)
})
expect(remove).not.toHaveBeenCalled()
expect(toastSuccess).toHaveBeenCalledWith(
'This server is already paired as Studio; updated its connection.'
)
})
it('still discards a brand-new server whose first connect fails', async () => {
verifyAndAddFromPairingCode.mockResolvedValue({
ok: true,
environment: { ...saved, id: 'env-new', name: 'Laptop' },
runtimeStatus: { runtimeId: 'runtime-b' }
})
const view = renderActions(async () => false)
await act(async () => {
await view.result.current.addEnvironment(false)
})
expect(remove).toHaveBeenCalledWith({ selector: 'env-new' })
})
it("lets main judge a saved server's own name, so its code can refresh it", async () => {
verifyAndAddFromPairingCode.mockResolvedValue({
ok: true,
environment: saved,
runtimeStatus: { runtimeId: 'runtime-a' },
updatedExisting: true
})
const view = renderActions(async () => true, 'Studio')
await act(async () => {
await view.result.current.addEnvironment(false)
})
expect(verifyAndAddFromPairingCode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Studio' })
)
expect(toastError).not.toHaveBeenCalled()
})
@@ -3,13 +3,11 @@ import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { findCollidingEnvironmentName } from '../../../../shared/runtime-environment-name-collision'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { isRuntimeEnvironmentRemovalBlocked } from './runtime-environment-host-details'
import type { RuntimeHostAccessFailure } from './RuntimeHostAccessFailureAlert'
type RuntimeEnvironmentMutationActionParams = {
environments: PublicKnownRuntimeEnvironment[]
settings: GlobalSettings
allowLocalRuntime: boolean
mountedRef: MutableRefObject<boolean>
@@ -22,7 +20,6 @@ type RuntimeEnvironmentMutationActionParams = {
}
export function useRuntimeEnvironmentMutationActions({
environments,
settings,
allowLocalRuntime,
mountedRef,
@@ -59,20 +56,10 @@ export function useRuntimeEnvironmentMutationActions({
)
return
}
const duplicate = findCollidingEnvironmentName(environments, trimmedName)
if (duplicate) {
toast.error(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.5ef712f407',
'A server named "{{value0}}" already exists.',
{ value0: duplicate.name }
)
)
return
}
setAddServerFailure(null)
setIsSaving(true)
try {
// Why main judges a taken name: a code for that same saved server refreshes it instead.
const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name: trimmedName,
pairingCode: trimmedPairingCode,
@@ -92,14 +79,24 @@ export function useRuntimeEnvironmentMutationActions({
environmentId: result.environment.id,
runtimeStatus: result.runtimeStatus
})
if (result.updatedExisting && mountedRef.current) {
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.serverAlreadyPaired',
'This server is already paired as {{value0}}; updated its connection.',
{ value0: result.environment.name }
)
)
}
if (!allowLocalRuntime) {
const connected = await connectEnvironment(result.environment)
if (!connected) {
// Why not a refreshed entry: removing it would drop a server the user had already saved.
if (!connected && !result.updatedExisting) {
await window.api.runtimeEnvironments.remove({ selector: result.environment.id })
await loadEnvironments()
return
}
} else {
} else if (!result.updatedExisting) {
if (mountedRef.current) {
toast.success(
translate(
@@ -286,7 +286,16 @@ export function AddRemoteHostDialog({
setRuntimeEnvironments(environments)
await readRuntimeHostStatusSnapshots()
toast.success(
translate('auto.components.sidebar.AddRemoteHostDialog.serverSaved', 'Remote server added.')
result.updatedExisting
? translate(
'auto.components.sidebar.AddRemoteHostDialog.serverAlreadyPaired',
'This server is already paired as {{value0}}; updated its connection.',
{ value0: result.environment.name }
)
: translate(
'auto.components.sidebar.AddRemoteHostDialog.serverSaved',
'Remote server added.'
)
)
reset()
onOpenChange(null)
+2 -1
View File
@@ -6643,6 +6643,7 @@
"sshSaveFailed": "Failed to add SSH host.",
"serverFieldsRequired": "Server name and pairing code are required.",
"serverSaved": "Remote server added.",
"serverAlreadyPaired": "This server is already paired as {{value0}}; updated its connection.",
"serverSaveFailed": "Failed to add remote server.",
"serverTitle": "Add remote server",
"sshTitle": "Add SSH host",
@@ -8677,7 +8678,7 @@
"b5b5114cb0": "Removed {{value0}}.",
"6cb6eae14f": "Failed to save runtime environment.",
"7b5986c8df": "Connected to {{value0}}. To create new projects there by default, choose it under Advanced > Default host for new projects.",
"5ef712f407": "A server named \"{{value0}}\" already exists.",
"serverAlreadyPaired": "This server is already paired as {{value0}}; updated its connection.",
"0c55a47480": "Name and pairing code are required.",
"e6410d72c3": "Failed to load runtime environments.",
"6ef71985da": "No endpoint",
+2 -1
View File
@@ -6389,6 +6389,7 @@
"sshSaveFailed": "No se pudo agregar el host SSH.",
"serverFieldsRequired": "Se requieren el nombre del servidor y el código de emparejamiento.",
"serverSaved": "Se agregó servidor remoto.",
"serverAlreadyPaired": "Este servidor ya está emparejado como {{value0}}; se actualizó su conexión.",
"serverSaveFailed": "No se pudo agregar el servidor remoto.",
"serverTitle": "Agregar servidor remoto",
"sshTitle": "Agregar host SSH",
@@ -8342,7 +8343,7 @@
"b5b5114cb0": "Se eliminó {{value0}}.",
"6cb6eae14f": "No se pudo guardar el runtime.",
"7b5986c8df": "Conectado a {{value0}}. Para crear proyectos nuevos allí de forma predeterminada, elígelo en Avanzado > Host predeterminado para proyectos nuevos.",
"5ef712f407": "Ya existe un servidor llamado \"{{value0}}\".",
"serverAlreadyPaired": "Este servidor ya está emparejado como {{value0}}; se actualizó su conexión.",
"0c55a47480": "Se requieren nombre y código de emparejamiento.",
"e6410d72c3": "No se pudieron cargar los runtimes.",
"6ef71985da": "Sin punto final",
+2 -1
View File
@@ -6476,6 +6476,7 @@
"sshSaveFailed": "Échec de l'ajout de l'hôte SSH.",
"serverFieldsRequired": "Le nom du serveur et le code d'appairage sont requis.",
"serverSaved": "Serveur distant ajouté.",
"serverAlreadyPaired": "Ce serveur est déjà associé sous le nom {{value0}} ; sa connexion a été mise à jour.",
"serverSaveFailed": "Échec de l'ajout du serveur distant.",
"serverTitle": "Ajouter un serveur distant",
"sshTitle": "Ajouter un hôte SSH",
@@ -8500,7 +8501,7 @@
"b5b5114cb0": "{{value0}} supprimé.",
"6cb6eae14f": "Échec de l'enregistrement de l'environnement d'exécution.",
"7b5986c8df": "Connecté à {{value0}}. Pour y créer les nouveaux projets par défaut, choisissez-le dans Avancé > Hôte par défaut des nouveaux projets.",
"5ef712f407": "Un serveur nommé « {{value0}} » existe déjà.",
"serverAlreadyPaired": "Ce serveur est déjà associé sous le nom {{value0}} ; sa connexion a été mise à jour.",
"0c55a47480": "Le nom et le code d'appairage sont requis.",
"e6410d72c3": "Échec du chargement des environnements d'exécution.",
"6ef71985da": "Aucun endpoint",
+2 -1
View File
@@ -6394,6 +6394,7 @@
"sshSaveFailed": "SSH ホストの追加に失敗しました。",
"serverFieldsRequired": "サーバー名とペアリングコードは必須です。",
"serverSaved": "リモートサーバーが追加されました。",
"serverAlreadyPaired": "このサーバーは {{value0}} としてペアリング済みです。接続情報を更新しました。",
"serverSaveFailed": "リモートサーバーの追加に失敗しました。",
"serverTitle": "リモートサーバーを追加する",
"sshTitle": "SSH ホストを追加",
@@ -8369,7 +8370,7 @@
"b5b5114cb0": "{{value0}} を削除しました。",
"6cb6eae14f": "実行環境の保存に失敗しました。",
"7b5986c8df": "{{value0}} に接続しました。新規プロジェクトを既定でそこに作成するには、[詳細設定] > [新規プロジェクトの既定ホスト] で選択してください。",
"5ef712f407": "「{{value0}}」という名前のサーバーはすでに存在します。",
"serverAlreadyPaired": "このサーバーは {{value0}} としてペアリング済みです。接続情報を更新しました。",
"0c55a47480": "名前とペアリングコードは必須です。",
"e6410d72c3": "ランタイム環境の読み込みに失敗しました。",
"6ef71985da": "エンドポイントなし",
+2 -1
View File
@@ -6394,6 +6394,7 @@
"sshSaveFailed": "SSH 호스트를 추가하지 못했습니다.",
"serverFieldsRequired": "서버 이름과 페어링 코드가 필요합니다.",
"serverSaved": "원격 서버가 추가되었습니다.",
"serverAlreadyPaired": "이 서버는 이미 {{value0}}(으)로 페어링되어 있습니다. 연결 정보를 업데이트했습니다.",
"serverSaveFailed": "원격 서버를 추가하지 못했습니다.",
"serverTitle": "원격 서버 추가",
"sshTitle": "SSH 호스트 추가",
@@ -8332,7 +8333,7 @@
"b5b5114cb0": "{{value0}}을(를) 삭제했습니다.",
"6cb6eae14f": "런타임 환경을 저장하지 못했습니다.",
"7b5986c8df": "{{value0}}에 연결되었습니다. 새 프로젝트를 기본적으로 그곳에 만들려면 고급 > 새 프로젝트의 기본 호스트에서 선택하세요.",
"5ef712f407": "\"{{value0}}\"이라는 서버가 이미 존재합니다.",
"serverAlreadyPaired": "이 서버는 이미 {{value0}}(으)로 페어링되어 있습니다. 연결 정보를 업데이트했습니다.",
"0c55a47480": "이름과 페어링 코드가 필요합니다.",
"e6410d72c3": "런타임 환경을 로드하지 못했습니다.",
"6ef71985da": "엔드포인트 없음",
+2 -1
View File
@@ -6394,6 +6394,7 @@
"sshSaveFailed": "添加 SSH 主机失败。",
"serverFieldsRequired": "必须填写服务器名称和配对码。",
"serverSaved": "已添加远程服务器。",
"serverAlreadyPaired": "此服务器已作为 {{value0}} 配对;已更新其连接。",
"serverSaveFailed": "添加远程服务器失败。",
"serverTitle": "添加远程服务器",
"sshTitle": "添加 SSH 主机",
@@ -8332,7 +8333,7 @@
"b5b5114cb0": "删除了 {{value0}}。",
"6cb6eae14f": "无法保存运行时环境。",
"7b5986c8df": "已连接到 {{value0}}。若要默认在其上创建新项目,请在“高级 > 新项目的默认主机”中选择它。",
"5ef712f407": "名为“{{value0}}”的服务器已存在。",
"serverAlreadyPaired": "此服务器已作为 {{value0}} 配对;已更新其连接。",
"0c55a47480": "需要名称和配对代码。",
"e6410d72c3": "无法加载运行时环境。",
"6ef71985da": "无终点",
@@ -58,13 +58,28 @@ describe('which re-paired environments name a different machine', () => {
expect(retired(managed(), managed({ generation: 2, pairingRevision: 2 }))).toEqual(['env-1'])
})
it('retires a re-paired environment that is not a managed server', () => {
it('retires a hand-paired server re-paired to a different key', () => {
const paired = { id: 'env-1', createdAt: 1, pairingRevision: 1, hostKeyFingerprint: 'k' }
expect(
classifyPeerReplacements([paired], [{ ...paired, pairingRevision: 2 }], ['env-1']).retired
classifyPeerReplacements(
[paired],
[{ ...paired, pairingRevision: 2, hostKeyFingerprint: 'other' }],
['env-1']
).retired
).toEqual(['env-1'])
})
it('keeps a hand-paired server whose new pairing carries the same key, so its tabs survive', () => {
const paired = { id: 'env-1', createdAt: 1, pairingRevision: 1, hostKeyFingerprint: 'k' }
expect(
classifyPeerReplacements([paired], [{ ...paired, pairingRevision: 2 }], ['env-1'])
).toEqual({
retired: [],
sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }],
pending: []
})
})
it('leaves an environment alone while its pairing is unchanged', () => {
expect(replacedRuntimeEnvironmentIds([managed()], [managed()])).toEqual([])
})
@@ -33,6 +33,11 @@ function sameRegistration(
before: CatalogEnvironment | undefined,
after: CatalogEnvironment | undefined
): boolean {
// Why a hand-paired server counts only with both keys: main replaces its pairing in place only for
// the saved key, so the key check below is the whole proof; without one there is none.
if (before && after && !before.orcadDeployment && !after.orcadDeployment) {
return Boolean(before.hostKeyFingerprint && after.hostKeyFingerprint)
}
// Why SSH only: a WSL deployment takes the stricter re-pair path until its host identity lands.
const left = before && getOrcadSshDeployment(before)
const right = after && getOrcadSshDeployment(after)
@@ -106,3 +106,28 @@ describe('an on-connect update re-pairs a managed server', () => {
expect(keptWorkspaces(store)).toEqual({ repos: 0, tabs: 0 })
})
})
describe('Settings refreshes a hand-paired server with a new code for it', () => {
function handPaired(pairingRevision: number, hostKeyFingerprint: string) {
const { orcadDeployment: _managed, ...rest } = server(pairingRevision, hostKeyFingerprint)
return rest
}
function userOnHandPairedServer() {
const store = userOnServer()
store.getState().setRuntimeEnvironments([handPaired(1, 'key-a')])
return store
}
it('keeps the workspaces and tabs when the new pairing carries the same key', () => {
const store = userOnHandPairedServer()
store.getState().setRuntimeEnvironments([handPaired(2, 'key-a')])
expect(keptWorkspaces(store)).toEqual({ repos: 1, tabs: 1 })
})
it('retires them when the new pairing is for another machine', () => {
const store = userOnHandPairedServer()
store.getState().setRuntimeEnvironments([handPaired(2, 'key-other')])
expect(keptWorkspaces(store)).toEqual({ repos: 0, tabs: 0 })
})
})
@@ -24,6 +24,8 @@ export type VerifyAndAddRuntimeEnvironmentResult =
ok: true
environment: PublicKnownRuntimeEnvironment
runtimeStatus: RuntimeStatus
/** The link was for a saved server, which kept its name and took the new connection. */
updatedExisting?: true
}
| RemotePairingFailure
@@ -0,0 +1,36 @@
import type { PairingOffer } from './pairing'
import type { KnownRuntimeEnvironment } from './runtime-environments'
/**
* The saved server holding this host key. Why the key: it is the server's persisted identity,
* so two pairings carrying it are one server whatever endpoint or name each was saved under.
*/
export function findEnvironmentWithHostKey<T extends Pick<KnownRuntimeEnvironment, 'endpoints'>>(
environments: readonly T[],
publicKeyB64: string
): T | undefined {
const key = canonicalHostKeyB64(publicKeyB64)
return environments.find((environment) =>
environment.endpoints.some((endpoint) => canonicalHostKeyB64(endpoint.publicKeyB64) === key)
)
}
/** Why: pairing links accept unpadded and URL-safe Base64, which decode to the same key. */
function canonicalHostKeyB64(publicKeyB64: string): string {
return publicKeyB64.trim().replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '')
}
/**
* The spelling a saved server already holds for this offer's key. Why: the host-key fingerprint
* hashes the string, so a respelled key would read as another machine and retire its workspaces.
*/
export function savedHostKeySpelling(
environment: Pick<KnownRuntimeEnvironment, 'endpoints'>,
offer: Pick<PairingOffer, 'publicKeyB64'>
): string {
const key = canonicalHostKeyB64(offer.publicKeyB64)
return (
environment.endpoints.find((endpoint) => canonicalHostKeyB64(endpoint.publicKeyB64) === key)
?.publicKeyB64 ?? offer.publicKeyB64
)
}
@@ -0,0 +1,70 @@
import {
isEphemeralVmRuntimeEnvironment,
type KnownRuntimeEnvironment,
type RuntimeEnvironmentSource
} from './runtime-environments'
import { RuntimeEnvironmentStoreError } from './runtime-environment-store-file'
/** Refuses replacing or removing a pairing that SSH access or a managed deployment depends on. */
export function assertNoIndependentSshAccess(environment: KnownRuntimeEnvironment): void {
if (environment.orcadDeployment) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
'This server is managed by Orca over SSH; its pairing cannot be replaced or removed here. To decommission it, run `orca environment stop --environment <name> --yes` or use Settings > Managed servers; for a host that is gone, run `orca environment forget --environment <name> --yes`.'
)
}
if (environment.pendingSshAccessOperation) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
"This server's SSH access change didn't finish. Retry or cancel it under Settings > Remote Orca Servers > SSH access for paired servers before replacing its pairing or removing it."
)
}
if (environment.sshAccess) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
"Unlink this server's SSH access under Settings > Remote Orca Servers > SSH access for paired servers before replacing its pairing or removing it."
)
}
}
/** Refuses folding a new code into a saved server that another flow owns, naming that server. */
export function assertPairingReplaceableByAdd(
environment: KnownRuntimeEnvironment,
source: RuntimeEnvironmentSource | undefined,
refreshable: boolean
): void {
const alreadyPaired = `This server is already paired as "${environment.name}".`
// Why: a cloud workspace pairs its own server on resume; a hand-made pairing would be swept with it.
if (isEphemeralVmRuntimeEnvironment(environment)) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
`${alreadyPaired} It belongs to a cloud workspace, which pairs it again when it resumes.`
)
}
if (source === 'ephemeral-vm') {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
`${alreadyPaired} The cloud workspace's server reuses that server's identity, so it was not saved.`
)
}
if (environment.orcadDeployment?.kind === 'wsl') {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
`${alreadyPaired} Orca manages it as a WSL host, so its pairing cannot be replaced here.`
)
}
try {
assertNoIndependentSshAccess(environment)
} catch (error) {
throw error instanceof RuntimeEnvironmentStoreError
? new RuntimeEnvironmentStoreError(error.code, `${alreadyPaired} ${error.message}`)
: error
}
// Why: only the app holding this server's connections can switch them to a new pairing.
if (!refreshable) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
`${alreadyPaired} To update its connection, add it again under Settings > Remote Orca Servers.`
)
}
}
@@ -112,7 +112,10 @@ describe('runtime environment sidecar across a downgrade', () => {
expect(() => removeEnvironment(userDataPath, access.id)).toThrow('Unlink')
const plain = addEnvironmentFromPairingCode(userDataPath, {
name: 'Plain',
pairingCode: encodePairingOffer(pairing)
pairingCode: encodePairingOffer({
...pairing,
publicKeyB64: Buffer.alloc(32, 2).toString('base64')
})
})
removeEnvironment(userDataPath, plain.id)
expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual([access.id])
@@ -174,9 +174,13 @@ describe('independent paired runtime SSH access persistence', () => {
it('refuses a second registration of the same execution runtime', () => {
const original = seed()
// Why another key: one key is one server, which the store would refresh instead of adding.
const duplicate = addEnvironmentFromPairingCode(userDataPath, {
name: 'Duplicate host',
pairingCode: encodePairingOffer(pairing)
pairingCode: encodePairingOffer({
...pairing,
publicKeyB64: Buffer.alloc(32, 2).toString('base64')
})
})
markEnvironmentUsed(userDataPath, duplicate.id, { runtimeId: 'host-runtime' })
const before = listEnvironments(userDataPath)
@@ -0,0 +1,197 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { encodePairingOffer } from './pairing'
import {
addEnvironmentFromPairingCode,
addOrUpdateEnvironmentFromPairingCode,
listEnvironments,
resolveEnvironmentPairingOffer
} from './runtime-environment-store'
import { addManagedOrcadEnvironment } from './runtime-environment-managed-orcad-store'
const serverKey = Buffer.alloc(32, 7).toString('base64')
function pairingCode(endpoint: string, deviceToken = 'first-token', publicKeyB64 = serverKey) {
return encodePairingOffer({ v: 2, endpoint, deviceToken, publicKeyB64 })
}
describe('pairing a server that is already saved', () => {
let userDataPath: string
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
beforeEach(() => {
// Why: this suite tests store contents, while secure-file tests cover Windows ACLs.
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-same-server-'))
})
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
rmSync(userDataPath, { recursive: true, force: true })
})
it('refreshes the saved entry instead of saving the server twice under a new name', () => {
const first = addEnvironmentFromPairingCode(userDataPath, {
name: 'Studio',
pairingCode: pairingCode('ws://192.0.2.10:6768'),
now: 100
})
const outcome = addOrUpdateEnvironmentFromPairingCode(userDataPath, {
name: 'Studio over relay',
pairingCode: pairingCode('wss://relay.example/runtime', 'second-token'),
runtimeId: 'runtime-after-restart',
now: 200
})
expect(outcome.updatedExisting).toBe(true)
expect(outcome.environment).toMatchObject({
id: first.id,
name: 'Studio',
createdAt: 100,
updatedAt: 200,
runtimeId: 'runtime-after-restart'
})
expect(outcome.environment.pairingRevision).toBeGreaterThan(first.pairingRevision ?? 0)
expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual([first.id])
expect(resolveEnvironmentPairingOffer(userDataPath, first.id)).toMatchObject({
endpoint: 'wss://relay.example/runtime',
deviceToken: 'second-token'
})
})
it('refuses a second entry for the same server where it cannot switch connections over', () => {
const first = addEnvironmentFromPairingCode(userDataPath, {
name: 'Studio',
pairingCode: pairingCode('ws://192.0.2.10:6768')
})
expect(() =>
addEnvironmentFromPairingCode(userDataPath, {
name: 'Studio again',
pairingCode: pairingCode('ws://192.0.2.10:6768', 'second-token')
})
).toThrow(
'This server is already paired as "Studio". To update its connection, add it again under Settings > Remote Orca Servers.'
)
expect(listEnvironments(userDataPath)).toEqual([first])
})
it("refreshes the saved server when the code is entered under the server's own name", () => {
const first = addEnvironmentFromPairingCode(userDataPath, {
name: 'Studio',
pairingCode: pairingCode('ws://192.0.2.10:6768')
})
const outcome = addOrUpdateEnvironmentFromPairingCode(userDataPath, {
name: 'Studio',
pairingCode: pairingCode('ws://192.0.2.10:6768', 'second-token')
})
expect(outcome).toMatchObject({ updatedExisting: true, environment: { id: first.id } })
})
it('matches the same key spelled unpadded or URL-safe', () => {
const first = addEnvironmentFromPairingCode(userDataPath, {
name: 'Studio',
pairingCode: pairingCode('ws://192.0.2.10:6768')
})
const urlSafeUnpadded = Buffer.alloc(32, 0xfb).toString('base64url')
addEnvironmentFromPairingCode(userDataPath, {
name: 'Wide',
pairingCode: pairingCode(
'ws://192.0.2.12:6768',
'token',
Buffer.alloc(32, 0xfb).toString('base64')
)
})
for (const spelling of [serverKey.replace(/=+$/, ''), urlSafeUnpadded]) {
const target = spelling === urlSafeUnpadded ? 'Wide' : 'Studio'
expect(
addOrUpdateEnvironmentFromPairingCode(userDataPath, {
name: `${target} again`,
pairingCode: pairingCode('ws://192.0.2.20:6768', 'other-token', spelling)
})
).toMatchObject({ updatedExisting: true, environment: { name: target } })
}
// The saved spelling stays, so the host-key fingerprint the renderer compares is unchanged.
expect(resolveEnvironmentPairingOffer(userDataPath, first.id)).toMatchObject({
publicKeyB64: serverKey,
deviceToken: 'other-token'
})
expect(resolveEnvironmentPairingOffer(userDataPath, 'Wide').publicKeyB64).toBe(
Buffer.alloc(32, 0xfb).toString('base64')
)
expect(listEnvironments(userDataPath).map((entry) => entry.id)).toContain(first.id)
expect(listEnvironments(userDataPath)).toHaveLength(2)
})
it('still saves a different server as its own entry', () => {
addEnvironmentFromPairingCode(userDataPath, {
name: 'Studio',
pairingCode: pairingCode('ws://192.0.2.10:6768')
})
const outcome = addOrUpdateEnvironmentFromPairingCode(userDataPath, {
name: 'Laptop',
pairingCode: pairingCode(
'ws://192.0.2.11:6768',
'token',
Buffer.alloc(32, 8).toString('base64')
)
})
expect(outcome.updatedExisting).toBe(false)
expect(listEnvironments(userDataPath).map((entry) => entry.name)).toEqual(['Laptop', 'Studio'])
})
it('takes the tunnel dependency from the new pairing, not the replaced one', () => {
const first = addEnvironmentFromPairingCode(userDataPath, {
name: 'Studio',
pairingCode: pairingCode('ws://127.0.0.1:41000/runtime'),
connectionDependency: 'ssh-tunnel'
})
expect(first.connectionDependency).toBe('ssh-tunnel')
const outcome = addOrUpdateEnvironmentFromPairingCode(userDataPath, {
name: 'Studio on LAN',
pairingCode: pairingCode('ws://192.0.2.10:6768')
})
expect(outcome.environment.connectionDependency).toBeUndefined()
})
it('refuses, naming the saved entry, when Orca manages that server over SSH', () => {
const managed = addManagedOrcadEnvironment(userDataPath, {
id: 'managed',
name: 'Managed box',
pairingCode: pairingCode('ws://127.0.0.1:41000/runtime'),
orcadDeployment: {
sshTargetId: 'target',
sshTargetGeneration: 1,
localPort: 41000,
remotePort: 6768
},
now: 100
})
expect(() =>
addOrUpdateEnvironmentFromPairingCode(userDataPath, {
name: 'Managed box by hand',
pairingCode: pairingCode('ws://192.0.2.10:6768', 'second-token')
})
).toThrow('This server is already paired as "Managed box".')
expect(listEnvironments(userDataPath)).toEqual([managed])
})
it('refuses to fold a cloud workspace server into a saved entry', () => {
const first = addEnvironmentFromPairingCode(userDataPath, {
name: 'Studio',
pairingCode: pairingCode('ws://192.0.2.10:6768')
})
expect(() =>
addOrUpdateEnvironmentFromPairingCode(userDataPath, {
name: 'VM',
pairingCode: pairingCode('ws://192.0.2.20:6768', 'vm-token'),
source: 'ephemeral-vm'
})
).toThrow('This server is already paired as "Studio".')
expect(listEnvironments(userDataPath)).toEqual([first])
})
})
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer } from './pairing'
import {
addEnvironmentFromPairingCode,
addOrUpdateEnvironmentFromPairingCode,
getEnvironmentStorePath,
listEnvironments,
markEnvironmentUsed,
@@ -50,10 +51,12 @@ describe('runtime environment store with records this build cannot read', () =>
rmSync(userDataPath, { recursive: true, force: true })
})
// Why a key per name: two names with one key are one server, which the store refreshes in place.
function add(name: string) {
const publicKeyB64 = Buffer.alloc(32, name).toString('base64')
return addEnvironmentFromPairingCode(userDataPath, {
name,
pairingCode: encodePairingOffer(pairing),
pairingCode: encodePairingOffer({ ...pairing, publicKeyB64 }),
now: 100
})
}
@@ -188,6 +191,17 @@ describe('runtime environment store with records this build cannot read', () =>
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Hiding 1 server'))
})
it('refuses a second entry for its server even though it is hidden', () => {
const { other } = setup()
expect(() =>
addOrUpdateEnvironmentFromPairingCode(userDataPath, {
name: 'Pinned again',
pairingCode: encodePairingOffer({ ...pairing, endpoint: 'ws://192.0.2.10:6768' })
})
).toThrow('already paired as "Pinned"')
expect(listEnvironments(userDataPath)).toEqual([other])
})
it('is never replaced, pruned, or orphaned by writes', () => {
const { pinned, other, sidecarPath } = setup()
const storeBefore = readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')
+11 -7
View File
@@ -13,12 +13,16 @@ import {
updateEnvironmentFromPairingCode
} from './runtime-environment-store'
function pairingCode(endpoint = 'ws://127.0.0.1:6768', pairedDeviceId?: string): string {
function pairingCode(
endpoint = 'ws://127.0.0.1:6768',
pairedDeviceId?: string,
keyFill = 1
): string {
return encodePairingOffer({
v: 2,
endpoint,
deviceToken: 'device-token',
publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64'),
publicKeyB64: Buffer.from(new Uint8Array(32).fill(keyFill)).toString('base64'),
...(pairedDeviceId ? { pairedDeviceId } : {})
})
}
@@ -104,7 +108,7 @@ describe('runtime environment store', () => {
expect(() =>
addEnvironmentFromPairingCode(userDataPath, {
name: 'dev box',
pairingCode: pairingCode('ws://192.0.2.10:6768')
pairingCode: pairingCode('ws://192.0.2.10:6768', undefined, 2)
})
).toThrow(RuntimeEnvironmentStoreError)
expect(listEnvironments(userDataPath)).toEqual([first])
@@ -123,7 +127,7 @@ describe('runtime environment store', () => {
expect(() =>
addEnvironmentFromPairingCode(userDataPath, {
name,
pairingCode: pairingCode('ws://192.0.2.10:6768')
pairingCode: pairingCode('ws://192.0.2.10:6768', undefined, 2)
})
).toThrow('A server named "build" already exists.')
}
@@ -176,7 +180,7 @@ describe('runtime environment store', () => {
const direct = addEnvironmentFromPairingCode(userDataPath, {
name: 'direct box',
pairingCode: pairingCode('ws://192.0.2.11:6768'),
pairingCode: pairingCode('ws://192.0.2.11:6768', undefined, 2),
connectionDependency: 'ssh-tunnel'
})
expect(direct).not.toHaveProperty('connectionDependency')
@@ -233,7 +237,7 @@ describe('runtime environment store', () => {
})
const legacy = addEnvironmentFromPairingCode(userDataPath, {
name: 'legacy box',
pairingCode: pairingCode('ws://192.0.2.10:6768'),
pairingCode: pairingCode('ws://192.0.2.10:6768', undefined, 2),
now: 1_000
})
@@ -270,7 +274,7 @@ describe('runtime environment store', () => {
expect(() =>
addEnvironmentFromPairingCode(userDataPath, {
name: 'x'.repeat(MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES),
pairingCode: pairingCode('ws://192.0.2.10:6768')
pairingCode: pairingCode('ws://192.0.2.10:6768', undefined, 2)
})
).toThrow(RuntimeEnvironmentStoreError)
expect(listEnvironments(userDataPath)).toEqual([first])
+90 -37
View File
@@ -17,6 +17,14 @@ import {
} from './runtime-environment-store-file'
import { writeRuntimeEnvironmentSidecarEntry } from './runtime-environment-sidecar'
import { findCollidingEnvironmentName } from './runtime-environment-name-collision'
import {
findEnvironmentWithHostKey,
savedHostKeySpelling
} from './runtime-environment-host-key-match'
import {
assertNoIndependentSshAccess,
assertPairingReplaceableByAdd
} from './runtime-environment-pairing-replace-guards'
export {
getEnvironmentStorePath,
@@ -32,19 +40,73 @@ export function listEnvironments(
return readEnvironmentStore(userDataPath, options).environments
}
export type AddEnvironmentArgs = {
name: string
pairingCode: string
now?: number
source?: RuntimeEnvironmentSource
connectionDependency?: 'ssh-tunnel'
/** Recorded only when the code turns out to be for an already saved server. */
runtimeId?: string
}
export type AddEnvironmentOutcome = {
environment: KnownRuntimeEnvironment
/** The code was for a saved server, whose pairing it replaced instead of adding a second entry. */
updatedExisting: boolean
}
/**
* Saves a pairing, refusing one for a server already saved. Why never a second entry: two entries
* for one server publish the same workspaces, which makes every write to them ambiguous.
*/
export function addEnvironmentFromPairingCode(
userDataPath: string,
args: {
name: string
pairingCode: string
now?: number
source?: RuntimeEnvironmentSource
connectionDependency?: 'ssh-tunnel'
}
args: AddEnvironmentArgs
): KnownRuntimeEnvironment {
return saveEnvironmentFromPairingCode(userDataPath, args, false).environment
}
/**
* Saves a pairing, or refreshes the saved server it is for. Only for callers that then retire the
* running app's connections to that server, which still authenticate with the replaced pairing.
*/
export function addOrUpdateEnvironmentFromPairingCode(
userDataPath: string,
args: AddEnvironmentArgs
): AddEnvironmentOutcome {
return saveEnvironmentFromPairingCode(userDataPath, args, true)
}
function saveEnvironmentFromPairingCode(
userDataPath: string,
args: AddEnvironmentArgs,
refreshSameServer: boolean
): AddEnvironmentOutcome {
const offer = parseRemotePairingCode(userDataPath, args.pairingCode)
const store = readPersistedEnvironmentStore(userDataPath)
const now = args.now ?? Date.now()
const store = readPersistedEnvironmentStore(userDataPath)
const samePersisted = findEnvironmentWithHostKey(store.environments, offer.publicKeyB64)
// Why the overlaid entry: SSH access or a deployment lives only there, and the guard reads it.
const samePaired =
samePersisted &&
readEnvironmentStore(userDataPath).environments.find((entry) => entry.id === samePersisted.id)
if (samePersisted && !samePaired) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
`This server is already paired as "${samePersisted.name}", whose saved settings this version of Orca cannot read.`
)
}
if (samePaired) {
assertPairingReplaceableByAdd(samePaired, args.source, refreshSameServer)
const sameSpelling = { ...offer, publicKeyB64: savedHostKeySpelling(samePaired, offer) }
const environment = replaceEnvironmentPairing(userDataPath, samePaired.id, sameSpelling, {
now,
runtimeId: args.runtimeId,
connectionDependency: args.connectionDependency
})
return { environment, updatedExisting: true }
}
const existing = findCollidingEnvironmentName(store.environments, args.name)
if (existing) {
throw new RuntimeEnvironmentStoreError(
@@ -69,7 +131,7 @@ export function addEnvironmentFromPairingCode(
].sort((a, b) => a.name.localeCompare(b.name))
}
writeEnvironmentStore(userDataPath, next)
return environment
return { environment, updatedExisting: false }
}
function parseRemotePairingCode(userDataPath: string, pairingCode: string): PairingOffer {
@@ -108,12 +170,24 @@ export function updateEnvironmentFromPairingCode(
args: { pairingCode: string; now?: number; runtimeId?: string }
): KnownRuntimeEnvironment {
const offer = parseRemotePairingCode(userDataPath, args.pairingCode)
assertNoIndependentSshAccess(
resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector)
)
const current = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector)
assertNoIndependentSshAccess(current)
return replaceEnvironmentPairing(userDataPath, selector, offer, {
now: args.now ?? Date.now(),
runtimeId: args.runtimeId,
connectionDependency: current.connectionDependency
})
}
/** Swaps a saved server's pairing in place, keeping its id, name and history. */
function replaceEnvironmentPairing(
userDataPath: string,
selector: string,
offer: PairingOffer,
args: { now: number; runtimeId?: string; connectionDependency?: 'ssh-tunnel' }
): KnownRuntimeEnvironment {
const store = readPersistedEnvironmentStore(userDataPath)
const existing = resolveEnvironmentFromStore(store, selector)
const now = args.now ?? Date.now()
const previousPairingRevision = existing.pairingRevision ?? existing.createdAt
const environment = createEnvironmentFromPairingOffer({
id: existing.id,
@@ -122,13 +196,13 @@ export function updateEnvironmentFromPairingCode(
offer,
runtimeId: args.runtimeId ?? existing.runtimeId,
...(existing.source ? { source: existing.source } : {}),
...getPairingConnectionDependency(existing.connectionDependency, offer)
...getPairingConnectionDependency(args.connectionDependency, offer)
})
const next = {
...environment,
createdAt: existing.createdAt,
updatedAt: now,
pairingRevision: Math.max(now, previousPairingRevision + 1),
updatedAt: args.now,
pairingRevision: Math.max(args.now, previousPairingRevision + 1),
lastUsedAt: existing.lastUsedAt
}
writeEnvironmentStore(userDataPath, {
@@ -262,24 +336,3 @@ export function resolveEnvironmentFromStore<T extends PersistedRuntimeEnvironmen
}
throw new RuntimeEnvironmentStoreError('invalid_argument', `Unknown environment: ${selector}`)
}
function assertNoIndependentSshAccess(environment: KnownRuntimeEnvironment): void {
if (environment.orcadDeployment) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
'This server is managed by Orca over SSH; its pairing cannot be replaced or removed here. To decommission it, run `orca environment stop --environment <name> --yes` or use Settings > Managed servers; for a host that is gone, run `orca environment forget --environment <name> --yes`.'
)
}
if (environment.pendingSshAccessOperation) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
"This server's SSH access change didn't finish. Retry or cancel it under Settings > Remote Orca Servers > SSH access for paired servers before replacing its pairing or removing it."
)
}
if (environment.sshAccess) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
"Unlink this server's SSH access under Settings > Remote Orca Servers > SSH access for paired servers before replacing its pairing or removing it."
)
}
}
@@ -84,8 +84,10 @@ describe('WSL host deployment store', () => {
function addWslManaged(record = wslRecord()) {
const environment = addEnvironmentFromPairingCode(userDataPath, {
name: 'Ubuntu',
// Why its own key: one key is one server, which the store refuses to save twice.
pairingCode: encodePairingOffer({
...pairing,
publicKeyB64: Buffer.alloc(32, 2).toString('base64'),
endpoint: `ws://127.0.0.1:${record.localPort}/runtime`
}),
now: 150
@@ -276,6 +278,7 @@ describe('WSL host deployment store', () => {
name: 'Remote',
pairingCode: encodePairingOffer({
...pairing,
publicKeyB64: Buffer.alloc(32, 3).toString('base64'),
endpoint: 'wss://build.example.com/runtime'
}),
now: 150