diff --git a/src/main/runtime/host-descriptor.test.ts b/src/main/runtime/host-descriptor.test.ts new file mode 100644 index 00000000000..43adeff1bfd --- /dev/null +++ b/src/main/runtime/host-descriptor.test.ts @@ -0,0 +1,98 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { RuntimeHostDescriptorSchema } from '../../shared/runtime-host-descriptor' +import { + HOST_INSTALLATION_FILENAME, + computeHostMachineBinding, + loadHostDescriptor, + loadOrCreateHostInstallationId +} from './host-descriptor' + +function profile(): string { + return mkdtempSync(join(tmpdir(), 'orca-host-descriptor-')) +} + +function storedId(userDataPath: string): unknown { + return JSON.parse(readFileSync(join(userDataPath, HOST_INSTALLATION_FILENAME), 'utf8')) + .installationId +} + +describe('loadOrCreateHostInstallationId', () => { + it('creates the id once and returns the same id on every later load', async () => { + const userDataPath = profile() + const first = await loadOrCreateHostInstallationId(userDataPath) + expect(await loadOrCreateHostInstallationId(userDataPath)).toBe(first) + expect(storedId(userDataPath)).toBe(first) + }) + + it('concurrent creators all return the one id that was stored', async () => { + const userDataPath = profile() + const ids = await Promise.all( + Array.from({ length: 4 }, () => loadOrCreateHostInstallationId(userDataPath)) + ) + expect(new Set(ids).size).toBe(1) + expect(storedId(userDataPath)).toBe(ids[0]) + }) + + it('concurrent repairs of a malformed file never fork the id', async () => { + const userDataPath = profile() + writeFileSync(join(userDataPath, HOST_INSTALLATION_FILENAME), '{not json') + const ids = await Promise.all( + Array.from({ length: 4 }, () => loadOrCreateHostInstallationId(userDataPath)) + ) + expect(new Set(ids).size).toBe(1) + expect(RuntimeHostDescriptorSchema.shape.installationId.safeParse(ids[0]).success).toBe(true) + expect(storedId(userDataPath)).toBe(ids[0]) + }) +}) + +describe('loadHostDescriptor', () => { + it('publishes an id and a binding keyed by that id', async () => { + const userDataPath = profile() + const descriptor = await loadHostDescriptor(userDataPath, () => 'machine-a') + expect(RuntimeHostDescriptorSchema.safeParse(descriptor).success).toBe(true) + expect(descriptor?.machineBinding).toBe( + computeHostMachineBinding(descriptor!.installationId, 'machine-a', userDataPath) + ) + expect(await loadHostDescriptor(userDataPath, () => 'machine-a')).toEqual(descriptor) + }) + + it('changes only the binding when the profile moves to another machine', async () => { + const userDataPath = profile() + const here = await loadHostDescriptor(userDataPath, () => 'machine-a') + const moved = await loadHostDescriptor(userDataPath, () => 'machine-b') + expect(moved?.installationId).toBe(here?.installationId) + expect(moved?.machineBinding).not.toBe(here?.machineBinding) + }) + + it('binds to the profile path, so a copied profile on the same machine differs', () => { + const id = '0b4c3d5e-1f2a-4b6c-8d7e-9f0a1b2c3d4e' + expect(computeHostMachineBinding(id, 'machine-a', '/profiles/one')).not.toBe( + computeHostMachineBinding(id, 'machine-a', '/profiles/two') + ) + }) + + it('never uses the same binding for two installations on one machine', () => { + expect( + computeHostMachineBinding('0b4c3d5e-1f2a-4b6c-8d7e-9f0a1b2c3d4e', 'machine-a', '/p') + ).not.toBe(computeHostMachineBinding('1b4c3d5e-1f2a-4b6c-8d7e-9f0a1b2c3d4e', 'machine-a', '/p')) + }) + + it('omits the binding when no machine id is readable', async () => { + const descriptor = await loadHostDescriptor(profile(), () => null) + expect(descriptor).toEqual({ installationId: descriptor?.installationId }) + }) + + it('publishes nothing, and writes nothing, when the stored id cannot be read', async () => { + const userDataPath = profile() + const blocker = join(userDataPath, 'blocker') + writeFileSync(blocker, '') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // A read that fails with anything but ENOENT (here ENOTDIR) must not mint a replacement id. + expect(await loadHostDescriptor(join(blocker, 'profile'), () => 'machine-a')).toBeNull() + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/host-descriptor.ts b/src/main/runtime/host-descriptor.ts new file mode 100644 index 00000000000..6ae80441246 --- /dev/null +++ b/src/main/runtime/host-descriptor.ts @@ -0,0 +1,121 @@ +import { createHmac, randomUUID } from 'node:crypto' +import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { + RuntimeHostDescriptorSchema, + type RuntimeHostDescriptor +} from '../../shared/runtime-host-descriptor' +import { profileStateAccessMachineIdentity } from '../persistence/profile-state/profile-state-access-identity' +import { withFileTransactionLock } from '../file-transaction-lock' + +export const HOST_INSTALLATION_FILENAME = 'host-installation.json' +const MAX_INSTALLATION_FILE_BYTES = 4 * 1024 +const InstallationFileSchema = RuntimeHostDescriptorSchema.pick({ installationId: true }) + +type InstallationRead = + | { kind: 'valid'; installationId: string } + | { kind: 'missing' } + | { kind: 'malformed' } + +function errorCode(error: unknown): unknown { + return error instanceof Error && 'code' in error ? error.code : undefined +} + +function readInstallation(filePath: string): InstallationRead { + let raw: string + try { + raw = readFileSync(filePath, 'utf8') + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return { kind: 'missing' } + } + // Why throw: regenerating over a file we merely cannot read would silently fork this host's identity. + throw error + } + if (raw.length > MAX_INSTALLATION_FILE_BYTES) { + return { kind: 'malformed' } + } + try { + const parsed = InstallationFileSchema.safeParse(JSON.parse(raw)) + return parsed.success + ? { kind: 'valid', installationId: parsed.data.installationId } + : { kind: 'malformed' } + } catch { + return { kind: 'malformed' } + } +} + +/** Reads this profile's installation id, creating it once; concurrent creators converge on one. */ +export async function loadOrCreateHostInstallationId(userDataPath: string): Promise { + const filePath = join(userDataPath, HOST_INSTALLATION_FILENAME) + const current = readInstallation(filePath) + if (current.kind === 'valid') { + return current.installationId + } + // Why the lock and a re-read: a creator or repairer decides only after any racing one has published. + return await withFileTransactionLock(filePath, async () => { + const locked = readInstallation(filePath) + if (locked.kind === 'valid') { + return locked.installationId + } + const installationId = randomUUID() + const draftPath = `${filePath}.${installationId}.draft` + try { + writeFileSync(draftPath, JSON.stringify({ installationId }), { flag: 'wx', mode: 0o600 }) + renameSync(draftPath, filePath) + } finally { + rmSync(draftPath, { force: true }) + } + return installationId + }) +} + +/** Keyed by the installation id so the binding never works as a cross-app machine identifier. */ +export function computeHostMachineBinding( + installationId: string, + machineIdentity: string, + userDataPath: string +): string { + return createHmac('sha256', installationId) + .update(`${machineIdentity}\0${resolve(userDataPath)}`) + .digest('base64url') +} + +/** Null when the profile cannot hold an installation id; the binding is dropped when no machine id is readable. */ +export async function loadHostDescriptor( + userDataPath: string, + readMachineIdentity: () => string | null = profileStateAccessMachineIdentity +): Promise { + let installationId: string + try { + installationId = await loadOrCreateHostInstallationId(userDataPath) + } catch (error) { + console.warn('[runtime] Host descriptor unavailable:', error) + return null + } + const machineIdentity = readMachineIdentity() + return machineIdentity + ? { + installationId, + machineBinding: computeHostMachineBinding(installationId, machineIdentity, userDataPath) + } + : { installationId } +} + +// Keyed by runtime id because the RPC server that owns the profile loads it, while status.get only sees the runtime. +const publishedByRuntimeId = new Map() + +export function publishHostDescriptor( + runtimeId: string, + descriptor: RuntimeHostDescriptor | null +): void { + if (descriptor) { + publishedByRuntimeId.set(runtimeId, descriptor) + } else { + publishedByRuntimeId.delete(runtimeId) + } +} + +export function getPublishedHostDescriptor(runtimeId: string): RuntimeHostDescriptor | null { + return publishedByRuntimeId.get(runtimeId) ?? null +} diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index cad7cded626..ddaebed0d7a 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -1,5 +1,6 @@ import { defineMethod } from '../core' import { getRemoteServerUpdaterSnapshot } from '../../remote-server-updater' +import { getPublishedHostDescriptor } from '../../host-descriptor' export const STATUS_METHODS = [ defineMethod({ @@ -11,8 +12,10 @@ export const STATUS_METHODS = [ // hostname; the wait is capped below the CLI's status probe so a slow lookup never reads as down. await runtime.machineNameReady() const snapshot = getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) + const hostDescriptor = getPublishedHostDescriptor(runtime.getRuntimeId()) return { ...runtime.getStatus(), + ...(hostDescriptor ? { hostDescriptor } : {}), ...(pairedDeviceId ? { pairedDeviceId } : {}), appVersion: snapshot.appVersion, remoteUpdateSupport: snapshot.support diff --git a/src/main/runtime/runtime-rpc-pairing-offer.test.ts b/src/main/runtime/runtime-rpc-pairing-offer.test.ts index ce443bc8cb0..1dabeea095d 100644 --- a/src/main/runtime/runtime-rpc-pairing-offer.test.ts +++ b/src/main/runtime/runtime-rpc-pairing-offer.test.ts @@ -6,6 +6,7 @@ import { OrcaRuntimeService } from './orca-runtime' import { OrcaRuntimeRpcServer } from './runtime-rpc' import { parsePairingCode } from '../../shared/pairing' import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' +import { STATUS_METHODS } from './rpc/methods/status' vi.mock('../git/worktree', () => { const worktrees = [ @@ -275,3 +276,40 @@ describe('OrcaRuntimeRpcServer', () => { } }) }) + +describe('host descriptor publication', () => { + it('status.get and runtime offers carry the same descriptor, stable across restarts', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const start = async () => { + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + return { runtime, server } + } + const statusOf = async (runtime: OrcaRuntimeService) => + (await STATUS_METHODS[0].handler(undefined, { runtime })).hostDescriptor + const first = await start() + const descriptor = await statusOf(first.runtime) + expect(descriptor?.installationId).toBeTruthy() + const runtimeOffer = first.server.createPairingOffer({ address: '127.0.0.1' }) + const mobileOffer = first.server.createPairingOffer({ address: '127.0.0.1', scope: 'mobile' }) + expect( + runtimeOffer.available && parsePairingCode(runtimeOffer.pairingUrl)?.hostDescriptor + ).toEqual(descriptor) + expect(mobileOffer.available && parsePairingCode(mobileOffer.pairingUrl)).toBeTruthy() + expect( + mobileOffer.available && parsePairingCode(mobileOffer.pairingUrl)?.hostDescriptor + ).toBeUndefined() + await first.server.stop() + + const second = await start() + expect(await statusOf(second.runtime)).toEqual(descriptor) + await second.server.stop() + expect(await statusOf(second.runtime)).toBeUndefined() + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index b1dd6276cdd..88c711ebd70 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -25,6 +25,7 @@ import { sweepOrphanedRuntimeSockets } from './runtime-rpc-socket-metadata' import { errorMessage } from '../../../shared/error-message' +import { loadHostDescriptor, publishHostDescriptor } from '../host-descriptor' export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { async start(): Promise { @@ -90,6 +91,8 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { this.deviceRegistry = pairingIdentity.deviceRegistry this.e2eeKeypair = pairingIdentity.e2eeKeypair this.pairingInitializationFailure = null + this.hostDescriptor = await loadHostDescriptor(this.userDataPath) + publishHostDescriptor(this.runtime.getRuntimeId(), this.hostDescriptor) try { const host = this.resolveInitialWebSocketBindHost() const { transport, endpoint } = await this.startWebSocketTransport({ diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 7a3a263f9ae..1cd22660652 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -182,13 +182,16 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { console.error('[runtime] Failed to persist pairing credential:', error) return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) } + const hostDescriptor = this.hostDescriptor const pairingUrl = encodePairingOffer({ v: PAIRING_OFFER_VERSION, endpoint, deviceToken: device.token, publicKeyB64, pairedDeviceId: device.deviceId, - scope + scope, + // Why runtime scope only: mobile never pins it, and every byte densifies the phone's QR. + ...(hostDescriptor && scope === 'runtime' ? { hostDescriptor } : {}) }) return { available: true, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts index c16bcda5510..2efd9bc7678 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts @@ -1,4 +1,5 @@ import { RuntimeRpcMobilePairing } from './runtime-rpc-mobile-pairing' +import { publishHostDescriptor } from '../host-descriptor' export type RuntimeRpcClientActivity = { openConnections: number @@ -37,6 +38,9 @@ export class RuntimeRpcShutdown extends RuntimeRpcMobilePairing { this.transports = [] this.metadataOwnershipWatch?.stop() this.metadataOwnershipWatch = null + if (this.hostDescriptor) { + publishHostDescriptor(this.runtime.getRuntimeId(), null) + } this.mobileSocketWiring = null this.detachWebSocketWiring = null const stopResults = await Promise.allSettled( diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index b33fd87534b..975085275ff 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -7,6 +7,7 @@ import type { RpcTransport } from '../rpc/transport' import type { WebSocket } from 'ws' import type { DeviceRegistry } from '../device-registry' import type { E2EEKeypair } from '../e2ee-keypair' +import type { RuntimeHostDescriptor } from '../../../shared/runtime-host-descriptor' import type { UnpairedDeviceAuthThrottle } from '../rpc/unpaired-device-auth-throttle' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' import { RelayRevokeOutbox } from '../relay/relay-revoke-outbox' @@ -60,6 +61,7 @@ export class RuntimeRpcState { protected readonly pushUnregisterOutbox: PushUnregisterOutbox protected deviceRegistry: DeviceRegistry | null = null protected e2eeKeypair: E2EEKeypair | null = null + protected hostDescriptor: RuntimeHostDescriptor | null = null protected pairingInitializationFailure: PairingOfferUnavailable | null = null protected tlsFingerprint: string | null = null protected activeTransports: RpcTransport[] = [] diff --git a/src/shared/mobile-relay-pairing-offer.ts b/src/shared/mobile-relay-pairing-offer.ts index 4f5f2cc3d8c..97c24f54c14 100644 --- a/src/shared/mobile-relay-pairing-offer.ts +++ b/src/shared/mobile-relay-pairing-offer.ts @@ -5,6 +5,7 @@ import { PAIRING_PUBLIC_KEY_MAX_CHARACTERS, PAIRING_RELAY_URL_MAX_CHARACTERS } from './mobile-pairing-protocol-limits' +import { RuntimeHostDescriptorSchema } from './runtime-host-descriptor' export const PAIRING_OFFER_VERSION = 2 const PairingScopeSchema = z.enum(['mobile', 'runtime']) @@ -77,7 +78,9 @@ export function createPairingOfferSchema(now: () => number = () => Date.now()) { publicKeyB64: z.string().min(1).max(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), pairedDeviceId: z.string().min(1).max(128).optional(), scope: PairingScopeSchema.optional(), - relay: relaySchema.optional() + relay: relaySchema.optional(), + // Why catch: a descriptor this build cannot read is dropped, never a reason to refuse pairing. + hostDescriptor: RuntimeHostDescriptorSchema.optional().catch(undefined) }) .superRefine((offer, ctx) => { if (offer.relay && offer.scope === 'runtime') { diff --git a/src/shared/runtime-host-descriptor.ts b/src/shared/runtime-host-descriptor.ts new file mode 100644 index 00000000000..e17b49721fb --- /dev/null +++ b/src/shared/runtime-host-descriptor.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' + +/** + * What a runtime host says about its own installation. `installationId` is generated once per + * profile; `machineBinding` is HMAC(installationId, machine id + profile path) and is only a hint + * that the profile moved or was copied, never an identity on its own. + */ +export const RuntimeHostDescriptorSchema = z.object({ + installationId: z + .string() + .regex(/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/), + machineBinding: z + .string() + .regex(/^[A-Za-z0-9_-]{43}$/) + .optional() +}) + +export type RuntimeHostDescriptor = z.infer diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 9780ff1487a..09106e78d0f 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -15,6 +15,7 @@ import type { RuntimeMobileSessionTerminalClientTab } from './runtime-mobile-session-tab-contracts' import type { CliStatusCaller } from './orchestration-caller-status' +import type { RuntimeHostDescriptor } from './runtime-host-descriptor' export type * from './runtime-mobile-session-tab-contracts' @@ -93,6 +94,8 @@ export type RuntimeStatus = { hostPlatform?: NodeJS.Platform /** Optional display name reported by the answering runtime. */ machineName?: string + /** Absent from older hosts and from hosts whose profile could not persist an installation id. */ + hostDescriptor?: RuntimeHostDescriptor terminalWindowsShell?: string | null deviceScope?: DeviceScope floatingWorkspaceEnabled?: boolean diff --git a/tests/e2e/cross-version-wire/cross-version-host-descriptor.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-host-descriptor.unit.test.ts new file mode 100644 index 00000000000..756db721f5e --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-host-descriptor.unit.test.ts @@ -0,0 +1,84 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +/** + * The host descriptor rides inside the pairing offer, which both sides parse with a schema. An old + * client must still accept an offer from a new host (dropping the field), and a new client must + * accept an offer from an old host that has none. + */ +const RELEASE_REF = 'v1.4.224' +const SUITE_TIMEOUT_MS = 180_000 + +type PairingModule = { + encodePairingOffer(offer: Record): string + parsePairingCode(input: string): Record | null +} + +function pairingModuleOf(module: Record): PairingModule { + const { encodePairingOffer, parsePairingCode } = module + if (typeof encodePairingOffer !== 'function' || typeof parsePairingCode !== 'function') { + throw new Error('module does not export the pairing codec') + } + return { + encodePairingOffer: (offer) => String(encodePairingOffer(offer)), + parsePairingCode: (input) => parsePairingCode(input) + } +} + +const BASE_OFFER = { + v: 2, + endpoint: 'ws://100.64.1.20:6768', + deviceToken: 'device-token', + publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + pairedDeviceId: 'device-1', + scope: 'runtime' +} +const DESCRIPTOR = { + installationId: '0b4c3d5e-1f2a-4b6c-8d7e-9f0a1b2c3d4e', + machineBinding: 'A'.repeat(43) +} + +let released: PairingModule +let current: PairingModule + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(RELEASE_REF) + const [oldModule, newModule] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/shared/pairing.ts'), + import('../../../src/shared/pairing') + ]) + released = pairingModuleOf(oldModule) + current = pairingModuleOf(newModule) +}, SUITE_TIMEOUT_MS) + +describe('cross-version host descriptor in the pairing offer', () => { + it('pairs two real builds', () => { + expect(released.parsePairingCode).not.toBe(current.parsePairingCode) + }) + + it('an old client pairs with a new host and drops the descriptor', () => { + const url = current.encodePairingOffer({ ...BASE_OFFER, hostDescriptor: DESCRIPTOR }) + expect(released.parsePairingCode(url)).toEqual(BASE_OFFER) + expect(current.parsePairingCode(url)).toEqual({ ...BASE_OFFER, hostDescriptor: DESCRIPTOR }) + }) + + it('a new client pairs with an old host that sends no descriptor', () => { + const url = released.encodePairingOffer(BASE_OFFER) + expect(current.parsePairingCode(url)).toEqual(BASE_OFFER) + }) + + it('a descriptor from a newer host in a shape this build cannot read never blocks pairing', () => { + const futureDescriptor = { installationId: 'some-future-format', machineBinding: 42 } + const url = released.encodePairingOffer(BASE_OFFER).replace( + /code=(.*)$/, + (_match, code: string) => + `code=${Buffer.from( + JSON.stringify({ + ...JSON.parse(Buffer.from(code, 'base64url').toString('utf8')), + hostDescriptor: futureDescriptor + }) + ).toString('base64url')}` + ) + expect(current.parsePairingCode(url)).toEqual(BASE_OFFER) + }) +})