feat(pairing): re-pairing the same server keeps its environment (#27197)

* feat(pairing): re-pairing the same server keeps its environment

The web client minted a new environment id on every runtime-scope pairing link,
even for the same server key, so existing terminals kept the old id and failed
as ambiguous. A key-matched re-pair now keeps the id (bumping the pairing
revision) and pins the server's host descriptor; a same-key server with a
different installation gets a new id, and a downgraded server never replaces
the pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): a reply from the replaced pairing never overwrites a same-server re-pair

Re-pairing now keeps the environment id, so fence on the pairing revision too:
a request captured before the re-pair can neither reopen a client with the
replaced token nor save the old pairing back over the new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
OrcaWin
2026-10-10 02:07:28 -07:00
committed by GitHub
co-authored by Claude Opus 5.5 m4air
parent dab3f6efeb
commit b80c420054
7 changed files with 345 additions and 19 deletions
@@ -91,6 +91,10 @@ export function getClientForEnvironment(
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
throw new Error('runtime_manually_disconnected')
}
// Why: a request captured before a re-pair must not reopen a client with the replaced token.
if (!isActivePairing(environment)) {
throw new Error(PAIRING_CHANGED_MESSAGE)
}
if (
!webRuntimeState.activeClient ||
webRuntimeState.activeClientEnvironmentId !== environment.id
@@ -173,17 +177,32 @@ export function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment |
return webRuntimeState.activeEnvironment
}
const PAIRING_CHANGED_MESSAGE = 'The paired Orca server changed while the request was in progress.'
export function assertActiveEnvironment(environmentId: string): void {
if (requireActiveEnvironment().id !== environmentId) {
throw new Error('The paired Orca server changed while the request was in progress.')
throw new Error(PAIRING_CHANGED_MESSAGE)
}
}
function pairingRevisionOf(environment: StoredWebRuntimeEnvironment): number {
return environment.pairingRevision ?? environment.createdAt
}
/** Re-pairing the same server keeps its id, so only the revision tells the pairings apart. */
function isActivePairing(environment: StoredWebRuntimeEnvironment): boolean {
const active = webRuntimeState.activeEnvironment
return (
active?.id === environment.id && pairingRevisionOf(active) === pairingRevisionOf(environment)
)
}
export function updateEnvironmentFromResponse(
environment: StoredWebRuntimeEnvironment,
response: RuntimeRpcResponse<unknown>
): void {
if (webRuntimeState.activeEnvironment?.id !== environment.id) {
const active = webRuntimeState.activeEnvironment
if (!active || !isActivePairing(environment)) {
return
}
const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null)
@@ -194,8 +213,9 @@ export function updateEnvironmentFromResponse(
typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string'
? (response.result as { pairedDeviceId: string }).pairedDeviceId
: undefined
// Why the active record: the captured one may predate another reply's update of this pairing.
webRuntimeState.activeEnvironment = updateStoredEnvironmentRuntimeId(
environment,
active,
runtimeId,
pairedDeviceId
)
+11
View File
@@ -49,6 +49,17 @@ describe('web pairing input', () => {
expect(parseWebPairingInput(encodeOffer({ endpoint }))).toMatchObject({ endpoint: expected })
})
it('keeps a readable host descriptor and drops an unreadable one without refusing', () => {
const hostDescriptor = { installationId: '11111111-1111-4111-8111-111111111111' }
expect(parseWebPairingInput(encodeOffer({ hostDescriptor }))).toEqual({
...offer,
hostDescriptor
})
expect(parseWebPairingInput(encodeOffer({ hostDescriptor: { installationId: 7 } }))).toEqual(
offer
)
})
it('treats invalid device scope metadata as unknown', () => {
expect(parseWebPairingInput(`orca://pair?code=${encodeOffer({ scope: 'admin' })}`)).toEqual(
offer
+9 -1
View File
@@ -1,3 +1,7 @@
import {
RuntimeHostDescriptorSchema,
type RuntimeHostDescriptor
} from '../../../shared/runtime-host-descriptor'
import type { DeviceScope } from '../../../shared/runtime-types'
const PAIRING_OFFER_VERSION = 2
@@ -9,6 +13,7 @@ export type WebPairingOffer = {
publicKeyB64: string
pairedDeviceId?: string
scope?: DeviceScope
hostDescriptor?: RuntimeHostDescriptor
}
export type WebPairingStartupDecision =
@@ -104,13 +109,16 @@ function decodePairingPayload(base64url: string): WebPairingOffer | null {
typeof parsed.pairedDeviceId === 'string' && parsed.pairedDeviceId.length > 0
? parsed.pairedDeviceId
: null
// Why safeParse: a descriptor this build cannot read is dropped, never a reason to refuse pairing.
const hostDescriptor = RuntimeHostDescriptorSchema.safeParse(parsed.hostDescriptor).data
return {
v: PAIRING_OFFER_VERSION,
endpoint: normalizeWebSocketEndpoint(parsed.endpoint),
deviceToken: parsed.deviceToken,
publicKeyB64: parsed.publicKeyB64,
...(pairedDeviceId ? { pairedDeviceId } : {}),
...(scope ? { scope } : {})
...(scope ? { scope } : {}),
...(hostDescriptor ? { hostDescriptor } : {})
}
}
@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import {
encodePairingCode,
installBrowserGlobals,
writeStoredRuntimeEnvironment
} from './web-preload-api-test-harness'
const ENVIRONMENT_KEY = 'orca.web.runtimeEnvironment.v1'
const INSTALL_A = '11111111-1111-4111-8111-111111111111'
const INSTALL_B = '22222222-2222-4222-8222-222222222222'
function readStored(storage: Storage): Record<string, unknown> {
const stored: unknown = JSON.parse(storage.getItem(ENVIRONMENT_KEY) ?? '{}')
return typeof stored === 'object' && stored !== null
? Object.fromEntries(Object.entries(stored))
: {}
}
async function installWithStoredServer(
pin?: Record<string, unknown>
): Promise<ReturnType<typeof installBrowserGlobals>> {
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
if (pin) {
globals.storage.setItem(
ENVIRONMENT_KEY,
JSON.stringify({ ...readStored(globals.storage), hostDescriptor: pin })
)
}
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
return globals
}
describe('web re-pairing the same server', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.doUnmock('./web-runtime-client')
})
it('never lets a reply from the replaced pairing overwrite the new one', async () => {
const tokens: string[] = []
const pending: { answer?: () => void } = {}
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
constructor(offer: { deviceToken: string }) {
tokens.push(offer.deviceToken)
}
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
const reply = { id: method, ok: true as const, result: {}, _meta: { runtimeId: 'r-1' } }
return new Promise((resolve) => {
pending.answer = () => resolve(reply)
})
}
close(): void {}
}
}))
const globals = await installWithStoredServer()
const staleCall = globals.window.api.runtimeEnvironments.call({
selector: 'web-server-a',
method: 'repos.list'
})
await vi.waitFor(() => expect(pending.answer).toBeDefined())
const answerStale = pending.answer
await globals.window.api.runtimeEnvironments.addFromPairingCode({
name: 'Server A',
pairingCode: encodePairingCode({ publicKeyB64: 'public-key', deviceToken: 'fresh-token' })
})
answerStale?.()
await staleCall
expect(readStored(globals.storage)).toMatchObject({
id: 'web-server-a',
endpoints: [{ deviceToken: 'fresh-token' }]
})
void globals.window.api.runtimeEnvironments.call({
selector: 'web-server-a',
method: 'repos.list'
})
await vi.waitFor(() => expect(tokens).toEqual(['token', 'fresh-token']))
})
it('keeps the execution-host id and refreshes the credentials (#11574)', async () => {
const globals = await installWithStoredServer()
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
name: 'Server A again',
pairingCode: encodePairingCode({
publicKeyB64: 'public-key',
deviceToken: 'fresh-token',
hostDescriptor: { installationId: INSTALL_A }
})
})
expect(paired.environment).toMatchObject({ id: 'web-server-a', createdAt: 1 })
expect(paired.environment.pairingRevision).toBeGreaterThan(1)
expect(paired.environment).not.toHaveProperty('hostDescriptor')
expect(readStored(globals.storage)).toMatchObject({
id: 'web-server-a',
hostDescriptor: { installationId: INSTALL_A },
endpoints: [{ id: 'ws-web-server-a', deviceToken: 'fresh-token' }]
})
expect(readStored(globals.storage)).not.toHaveProperty('compatibleEnvironmentIds')
})
it('mints a new id for a same-key server whose pinned installation changed', async () => {
const globals = await installWithStoredServer({ installationId: INSTALL_A })
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
name: 'Clone',
pairingCode: encodePairingCode({
publicKeyB64: 'public-key',
hostDescriptor: { installationId: INSTALL_B }
})
})
expect(paired.environment.id).not.toBe('web-server-a')
expect(readStored(globals.storage)).toMatchObject({
hostDescriptor: { installationId: INSTALL_B }
})
})
it('keeps the old pin when a downgraded server sends no descriptor', async () => {
const globals = await installWithStoredServer({ installationId: INSTALL_A })
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
name: 'Server A',
pairingCode: encodePairingCode({ publicKeyB64: 'public-key' })
})
expect(paired.environment.id).toBe('web-server-a')
expect(readStored(globals.storage)).toMatchObject({
hostDescriptor: { installationId: INSTALL_A }
})
})
it('drops a malformed pinned descriptor instead of trusting it', async () => {
const globals = await installWithStoredServer({ installationId: 'not-a-uuid' })
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
name: 'Server A',
pairingCode: encodePairingCode({
publicKeyB64: 'public-key',
hostDescriptor: { installationId: INSTALL_B }
})
})
expect(paired.environment.id).toBe('web-server-a')
expect(readStored(globals.storage)).toMatchObject({
hostDescriptor: { installationId: INSTALL_B }
})
})
})
+38 -15
View File
@@ -1,10 +1,18 @@
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
import {
RuntimeHostDescriptorSchema,
type RuntimeHostDescriptor
} from '../../../shared/runtime-host-descriptor'
import { classifyRuntimeHostRePair } from '../../../shared/runtime-host-pairing-identity'
import type { WebPairingOffer } from './web-pairing'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { translate } from '@/i18n/i18n'
export type StoredWebRuntimeEnvironment = Omit<PublicKnownRuntimeEnvironment, 'endpoints'> & {
/** Ids minted by re-pairs before re-pairing kept the id; read-only, never extended. */
compatibleEnvironmentIds?: string[]
/** The server's descriptor pinned at pairing; see classifyRuntimeHostRePair. */
hostDescriptor?: RuntimeHostDescriptor
endpoints: {
id: string
kind: 'websocket'
@@ -41,15 +49,18 @@ export function readStoredWebRuntimeEnvironment(): StoredWebRuntimeEnvironment |
typeof parsed.pairedDeviceId === 'string' && parsed.pairedDeviceId.trim().length > 0
? parsed.pairedDeviceId.trim()
: null
const hostDescriptor = RuntimeHostDescriptorSchema.safeParse(parsed.hostDescriptor).data
const {
compatibleEnvironmentIds: _unvalidatedIds,
pairedDeviceId: _unvalidatedDeviceId,
hostDescriptor: _unvalidatedDescriptor,
...environment
} = parsed
return {
...environment,
...(pairedDeviceId ? { pairedDeviceId } : {}),
...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {})
...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}),
...(hostDescriptor ? { hostDescriptor } : {})
}
} catch {
return null
@@ -70,19 +81,37 @@ export function createStoredWebRuntimeEnvironment(args: {
previousEnvironment?: StoredWebRuntimeEnvironment | null
connectionDependency?: 'ssh-tunnel'
}): StoredWebRuntimeEnvironment {
const id = `web-${createBrowserUuid()}`
const previous = args.previousEnvironment
const match = previous
? classifyRuntimeHostRePair(
{
publicKeys: previous.endpoints.map((endpoint) => endpoint.publicKeyB64),
pin: previous.hostDescriptor
},
args.offer
)
: { kind: 'different-host' as const }
// Why keep the id: it is this server's execution-host identity; a new one strands its sessions (#11574).
const sameHost = match.kind === 'same-host' ? previous : null
const id = sameHost?.id ?? `web-${createBrowserUuid()}`
const now = Date.now()
const compatibleEnvironmentIds = getCompatibleEnvironmentIds(args.previousEnvironment, args.offer)
const pin = match.kind === 'same-host' ? match.pin : args.offer.hostDescriptor
const compatibleEnvironmentIds = sameHost?.compatibleEnvironmentIds ?? []
return {
id,
name: args.name.trim() || 'Orca Server',
createdAt: now,
createdAt: sameHost?.createdAt ?? now,
updatedAt: now,
// Why bump: request fences compare revisions, and the device token they captured is gone.
...(sameHost
? { pairingRevision: Math.max(now, (sameHost.pairingRevision ?? sameHost.createdAt) + 1) }
: {}),
lastUsedAt: null,
runtimeId: null,
...(args.offer.pairedDeviceId ? { pairedDeviceId: args.offer.pairedDeviceId } : {}),
...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}),
...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}),
...(pin ? { hostDescriptor: pin } : {}),
preferredEndpointId: `ws-${id}`,
endpoints: [
{
@@ -97,20 +126,14 @@ export function createStoredWebRuntimeEnvironment(args: {
}
}
function getCompatibleEnvironmentIds(
previous: StoredWebRuntimeEnvironment | null | undefined,
offer: WebPairingOffer
): string[] {
if (!previous?.endpoints.some((endpoint) => endpoint.publicKeyB64 === offer.publicKeyB64)) {
return []
}
return [...new Set([...(previous.compatibleEnvironmentIds ?? []), previous.id])]
}
export function redactStoredWebRuntimeEnvironment(
environment: StoredWebRuntimeEnvironment
): PublicKnownRuntimeEnvironment {
const { compatibleEnvironmentIds: _compatibleEnvironmentIds, ...publicEnvironment } = environment
const {
compatibleEnvironmentIds: _compatibleEnvironmentIds,
hostDescriptor: _hostDescriptor,
...publicEnvironment
} = environment
return {
...publicEnvironment,
endpoints: environment.endpoints.map(
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { classifyRuntimeHostRePair } from './runtime-host-pairing-identity'
const INSTALL_A = '11111111-1111-4111-8111-111111111111'
const INSTALL_B = '22222222-2222-4222-8222-222222222222'
const BIND_1 = 'a'.repeat(43)
const BIND_2 = 'b'.repeat(43)
describe('classifyRuntimeHostRePair', () => {
it('treats a different host key as a different server, whatever the descriptor says', () => {
expect(
classifyRuntimeHostRePair(
{ publicKeys: ['key-a'], pin: { installationId: INSTALL_A } },
{ publicKeyB64: 'key-b', hostDescriptor: { installationId: INSTALL_A } }
)
).toEqual({ kind: 'different-host' })
})
it('keeps an unpinned server matched by key and pins its first descriptor', () => {
expect(classifyRuntimeHostRePair({ publicKeys: ['key-a'] }, { publicKeyB64: 'key-a' })).toEqual(
{ kind: 'same-host', pin: undefined, descriptor: 'unpinned' }
)
expect(
classifyRuntimeHostRePair(
{ publicKeys: ['key-a'] },
{ publicKeyB64: 'key-a', hostDescriptor: { installationId: INSTALL_A } }
)
).toEqual({ kind: 'same-host', pin: { installationId: INSTALL_A }, descriptor: 'pinned' })
})
it('separates a same-key server whose installation changed', () => {
expect(
classifyRuntimeHostRePair(
{ publicKeys: ['key-a'], pin: { installationId: INSTALL_A } },
{ publicKeyB64: 'key-a', hostDescriptor: { installationId: INSTALL_B } }
)
).toEqual({ kind: 'different-host' })
})
it('keeps a moved or reinstalled server and re-pins its new binding', () => {
const next = { installationId: INSTALL_A, machineBinding: BIND_2 }
expect(
classifyRuntimeHostRePair(
{ publicKeys: ['key-a'], pin: { installationId: INSTALL_A, machineBinding: BIND_1 } },
{ publicKeyB64: 'key-a', hostDescriptor: next }
)
).toEqual({ kind: 'same-host', pin: next, descriptor: 'moved' })
})
it('never learns from a downgraded server that stopped sending a descriptor', () => {
const pin = { installationId: INSTALL_A, machineBinding: BIND_1 }
expect(
classifyRuntimeHostRePair({ publicKeys: ['key-a'], pin }, { publicKeyB64: 'key-a' })
).toEqual({ kind: 'same-host', pin, descriptor: 'unverifiable' })
})
})
@@ -0,0 +1,47 @@
import type { RuntimeHostDescriptor } from './runtime-host-descriptor'
/**
* How a new pairing offer relates to a server this client already pairs with.
* `pin` is the descriptor to keep pinned. `descriptor`:
* - `pinned`: the offer's descriptor matches (or is now pinned for the first time).
* - `moved`: same installation, different machine binding: the profile moved or was reinstalled.
* - `unverifiable`: a pinned server now sends no descriptor (downgraded); the old pin is kept.
* - `unpinned`: neither side has a descriptor yet.
*/
export type RuntimeHostRePairMatch =
| {
kind: 'same-host'
pin: RuntimeHostDescriptor | undefined
descriptor: 'pinned' | 'moved' | 'unverifiable' | 'unpinned'
}
| { kind: 'different-host' }
/**
* The host key, which the pairing handshake proves, is the trust root: a different key is a
* different server. The descriptor only separates a reinstall or a deliberate clone that kept the key.
*/
export function classifyRuntimeHostRePair(
previous: { publicKeys: readonly string[]; pin?: RuntimeHostDescriptor },
offer: { publicKeyB64: string; hostDescriptor?: RuntimeHostDescriptor }
): RuntimeHostRePairMatch {
if (!previous.publicKeys.includes(offer.publicKeyB64)) {
return { kind: 'different-host' }
}
const { pin } = previous
const next = offer.hostDescriptor
if (!next) {
// Why keep the old pin: an older server must never let this client learn a replacement.
return { kind: 'same-host', pin, descriptor: pin ? 'unverifiable' : 'unpinned' }
}
if (!pin) {
return { kind: 'same-host', pin: next, descriptor: 'pinned' }
}
if (pin.installationId !== next.installationId) {
return { kind: 'different-host' }
}
const moved =
pin.machineBinding !== undefined &&
next.machineBinding !== undefined &&
pin.machineBinding !== next.machineBinding
return { kind: 'same-host', pin: next, descriptor: moved ? 'moved' : 'pinned' }
}