From 60b8eb4a6f87ffde45f2bbf623aac8cb77ecfd72 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:00:06 -0700 Subject: [PATCH] fix(browser): keep in-app browser logins when a WSL or managed SSH host re-pairs (#29610) Client-hosted browser pages for a server Orca deployed (WSL hosts today; managed SSH desktop tabs use the SSH route) now derive their partition from the environment and the server's public key, not the pairing revision and device id that rotate on every re-pair. Existing jars are adopted, and each binding keeps the shipping build's fingerprint (with the stable one as an alias) so a downgraded build still opens the same jar. --- ...ient-page-command-executor-dependencies.ts | 18 +- ...ient-page-command-executor-fencing.test.ts | 2 + ...ient-page-command-executor-test-harness.ts | 1 + ...owser-client-page-command-executor.test.ts | 7 + .../browser-client-page-command-executor.ts | 22 +- ...er-client-page-command-integration.test.ts | 1 + .../browser/browser-client-page-creation.ts | 11 +- ...t-page-execution-host-supersession.test.ts | 1 + ...lient-page-reconciliation-adapters.test.ts | 1 + ...browser-client-page-unavailability.test.ts | 1 + ...ute-cookie-import-partition-parity.test.ts | 1 + ...browser-client-route-cookie-import.test.ts | 5 +- .../browser-client-route-cookie-import.ts | 13 +- .../browser-route-partition-binding-file.ts | 37 +- ...wser-route-partition-binding-store-fake.ts | 29 +- ...wser-route-partition-binding-store.test.ts | 45 +++ .../browser-route-partition-binding-store.ts | 53 ++- .../browser-route-partition-migration.test.ts | 27 ++ .../browser-route-partition-migration.ts | 86 +++-- ...route-partition-storage-retirement.test.ts | 3 + ...browser-route-session-partition-binding.ts | 13 +- ...browser-route-session-registry-contract.ts | 9 +- .../browser/browser-route-session-registry.ts | 7 +- .../browser/browser-route-session-runtime.ts | 12 +- .../browser/browser-route-session-state.ts | 2 + ...ired-runtime-browser-authority-identity.ts | 102 ++++++ ...me-browser-client-host-composition.test.ts | 2 + ...browser-client-host-route-identity.test.ts | 322 +++++++++++++++++- ...red-runtime-browser-client-host-runtime.ts | 96 ++---- 29 files changed, 775 insertions(+), 154 deletions(-) create mode 100644 src/main/browser/paired-runtime-browser-authority-identity.ts diff --git a/src/main/browser/browser-client-page-command-executor-dependencies.ts b/src/main/browser/browser-client-page-command-executor-dependencies.ts index 227de48630a..81eb19e9911 100644 --- a/src/main/browser/browser-client-page-command-executor-dependencies.ts +++ b/src/main/browser/browser-client-page-command-executor-dependencies.ts @@ -15,14 +15,28 @@ import type { /** * Storage identity of the connection a page's partition is derived from. * - * The legacy half names the partition an older build already populated, so a client - * upgrade adopts that jar instead of deriving an empty one. + * The superseded identities name partitions an older build already populated, so a client + * upgrade adopts that jar instead of deriving an empty one: `pairingAuthorityConnectionIdentity` + * is the pairing-revision identity (equal to the current one for hosts that never re-pair in + * place), and `legacyAuthorityConnectionIdentity` the older per-process one. */ export type BrowserClientPageAuthorityIdentity = { authorityConnectionIdentity: string + pairingAuthorityConnectionIdentity: string legacyAuthorityConnectionIdentity: string } +// Why pick: transition inputs carry the whole host start, and only the identity belongs here. +export function pageAuthorityIdentity( + input: BrowserClientPageAuthorityIdentity +): BrowserClientPageAuthorityIdentity { + return { + authorityConnectionIdentity: input.authorityConnectionIdentity, + pairingAuthorityConnectionIdentity: input.pairingAuthorityConnectionIdentity, + legacyAuthorityConnectionIdentity: input.legacyAuthorityConnectionIdentity + } +} + export type BrowserClientPageCommandExecutorDependencies = BrowserClientPageAuthorityIdentity & { orcaProfileId: string /** Environment record that owns this host's partitions for storage lifecycle. */ diff --git a/src/main/browser/browser-client-page-command-executor-fencing.test.ts b/src/main/browser/browser-client-page-command-executor-fencing.test.ts index a90f2810dac..15002a2a67e 100644 --- a/src/main/browser/browser-client-page-command-executor-fencing.test.ts +++ b/src/main/browser/browser-client-page-command-executor-fencing.test.ts @@ -87,6 +87,7 @@ describe('BrowserClientPageCommandExecutor authority fencing', () => { executor.completeAuthorityTransition({ authorityConnectionIdentity: 'authority-record-b', + pairingAuthorityConnectionIdentity: 'pairing-authority-record-b', legacyAuthorityConnectionIdentity: 'legacy-authority-record-b' }) await expect( @@ -146,6 +147,7 @@ describe('BrowserClientPageCommandExecutor authority fencing', () => { expect(order).toEqual(['release-route']) executor.completeAuthorityTransition({ authorityConnectionIdentity: 'authority-record-b', + pairingAuthorityConnectionIdentity: 'pairing-authority-record-b', legacyAuthorityConnectionIdentity: 'legacy-authority-record-b' }) await executor.close() diff --git a/src/main/browser/browser-client-page-command-executor-test-harness.ts b/src/main/browser/browser-client-page-command-executor-test-harness.ts index 8e54733ef58..2854a14a64f 100644 --- a/src/main/browser/browser-client-page-command-executor-test-harness.ts +++ b/src/main/browser/browser-client-page-command-executor-test-harness.ts @@ -81,6 +81,7 @@ export function createHarness(options: { maxPages?: number } = {}) { const dependencies = { orcaProfileId: 'orca-profile-a', authorityConnectionIdentity: 'authority-record-a', + pairingAuthorityConnectionIdentity: 'pairing-authority-record-a', legacyAuthorityConnectionIdentity: 'legacy-authority-record-a', storageScope: 'a'.repeat(64), maxPages: options.maxPages, diff --git a/src/main/browser/browser-client-page-command-executor.test.ts b/src/main/browser/browser-client-page-command-executor.test.ts index 071b409d969..4cf1ec8690c 100644 --- a/src/main/browser/browser-client-page-command-executor.test.ts +++ b/src/main/browser/browser-client-page-command-executor.test.ts @@ -48,6 +48,12 @@ describe('BrowserClientPageCommandExecutor', () => { authorityConnectionIdentity: 'authority-record-a', executionHostIdentity: 'execution-host-record-a' }, + compatibleIdentity: { + orcaProfileId: 'orca-profile-a', + browserProfileId: 'profile-a', + authorityConnectionIdentity: 'pairing-authority-record-a', + executionHostIdentity: 'execution-host-record-a' + }, legacyIdentity: { orcaProfileId: 'orca-profile-a', browserProfileId: 'profile-a', @@ -526,6 +532,7 @@ describe('BrowserClientPageCommandExecutor', () => { expect(() => executor.completeAuthorityTransition({ authorityConnectionIdentity: 'authority-record-b', + pairingAuthorityConnectionIdentity: 'pairing-authority-record-b', legacyAuthorityConnectionIdentity: 'legacy-authority-record-b' }) ).toThrow('browser_client_page_authority_transition_unavailable') diff --git a/src/main/browser/browser-client-page-command-executor.ts b/src/main/browser/browser-client-page-command-executor.ts index 2d7c10940fb..8dbc14c91af 100644 --- a/src/main/browser/browser-client-page-command-executor.ts +++ b/src/main/browser/browser-client-page-command-executor.ts @@ -38,9 +38,10 @@ import { navigateBrowserClientPageCommand } from './browser-client-page-command-execution' import { publishBrowserClientRetainedPage } from './browser-client-page-availability' -import type { - BrowserClientPageAuthorityIdentity, - BrowserClientPageCommandExecutorDependencies +import { + pageAuthorityIdentity, + type BrowserClientPageAuthorityIdentity, + type BrowserClientPageCommandExecutorDependencies } from './browser-client-page-command-executor-dependencies' export class BrowserClientPageCommandExecutor { @@ -51,14 +52,12 @@ export class BrowserClientPageCommandExecutor { private readonly profileSwitches = new BrowserClientProfileSwitches() private readonly navigationFence = new BrowserClientPageNavigationFence() private closePromise: Promise | null = null - private authorityConnectionIdentity: string - private legacyAuthorityConnectionIdentity: string + private authority: BrowserClientPageAuthorityIdentity private authorityTransitioning = false private closed = false constructor(private readonly dependencies: BrowserClientPageCommandExecutorDependencies) { - this.authorityConnectionIdentity = dependencies.authorityConnectionIdentity - this.legacyAuthorityConnectionIdentity = dependencies.legacyAuthorityConnectionIdentity + this.authority = pageAuthorityIdentity(dependencies) this.maxPages = dependencies.maxPages ?? BROWSER_CLIENT_HOST_PAGE_INVENTORY_MAX_PAGES if ( !Number.isInteger(this.maxPages) || @@ -103,8 +102,7 @@ export class BrowserClientPageCommandExecutor { pages: this.pages, dependencies: { ...this.dependencies, - authorityConnectionIdentity: this.authorityConnectionIdentity, - legacyAuthorityConnectionIdentity: this.legacyAuthorityConnectionIdentity + ...this.authority }, assertAvailable: () => this.navigationFence.assertAvailable(this.closed || this.authorityTransitioning), @@ -212,8 +210,7 @@ export class BrowserClientPageCommandExecutor { if (this.closed || !this.authorityTransitioning || !input.authorityConnectionIdentity) { throw new Error('browser_client_page_authority_transition_unavailable') } - this.authorityConnectionIdentity = input.authorityConnectionIdentity - this.legacyAuthorityConnectionIdentity = input.legacyAuthorityConnectionIdentity + this.authority = pageAuthorityIdentity(input) this.authorityTransitioning = false } @@ -260,8 +257,7 @@ export class BrowserClientPageCommandExecutor { await createReservedBrowserClientPage( { ...this.dependencies, - authorityConnectionIdentity: this.authorityConnectionIdentity, - legacyAuthorityConnectionIdentity: this.legacyAuthorityConnectionIdentity, + ...this.authority, retireSupersededExecutionHostPages: (route) => retireSupersededExecutionHostPages(this.pages.values(), route, (id, generation) => this.retirePage(id, generation) diff --git a/src/main/browser/browser-client-page-command-integration.test.ts b/src/main/browser/browser-client-page-command-integration.test.ts index 4d416fba868..6e5a4997234 100644 --- a/src/main/browser/browser-client-page-command-integration.test.ts +++ b/src/main/browser/browser-client-page-command-integration.test.ts @@ -47,6 +47,7 @@ describe('BrowserClientPageCommandExecutor integration', () => { const executor = new BrowserClientPageCommandExecutor({ orcaProfileId: 'orca-profile-a', authorityConnectionIdentity: 'authority-record-a', + pairingAuthorityConnectionIdentity: 'pairing-authority-record-a', legacyAuthorityConnectionIdentity: 'legacy-authority-record-a', storageScope: 'a'.repeat(64), retainNetworkRoute: vi.fn(async () => ({ diff --git a/src/main/browser/browser-client-page-creation.ts b/src/main/browser/browser-client-page-creation.ts index 38f0d06d49b..06e7bef7d8e 100644 --- a/src/main/browser/browser-client-page-creation.ts +++ b/src/main/browser/browser-client-page-creation.ts @@ -9,6 +9,7 @@ import { assertCurrentBrowserClientPageRenderer, browserClientPageIdentity } from './browser-client-page-command-admission' +import type { BrowserClientPageAuthorityIdentity } from './browser-client-page-command-executor-dependencies' import { BrowserClientPageCommandError } from './browser-client-page-command-failure' import type { BrowserClientPageGuestBinding } from './browser-client-page-guest-binding' import { createBrowserClientPageInventory } from './browser-client-page-inventory' @@ -23,10 +24,8 @@ import type { import type { BrowserRouteSessionRegistry } from './browser-route-session-registry' import type { BrowserRouteSessionHandle } from './browser-route-session-state' -type BrowserClientPageCreationDependencies = { +type BrowserClientPageCreationDependencies = BrowserClientPageAuthorityIdentity & { orcaProfileId: string - authorityConnectionIdentity: string - legacyAuthorityConnectionIdentity: string storageScope: string retainNetworkRoute(executionHostKey: string, signal: AbortSignal): Promise retireSupersededExecutionHostPages(route: RetainedNetworkRoute): Promise @@ -74,6 +73,12 @@ export async function createReservedBrowserClientPage( authorityConnectionIdentity: dependencies.authorityConnectionIdentity, executionHostIdentity: route.executionHostIdentity }, + compatibleIdentity: { + orcaProfileId: dependencies.orcaProfileId, + browserProfileId: event.command.browserProfileId, + authorityConnectionIdentity: dependencies.pairingAuthorityConnectionIdentity, + executionHostIdentity: route.executionHostIdentity + }, legacyIdentity: { orcaProfileId: dependencies.orcaProfileId, browserProfileId: event.command.browserProfileId, diff --git a/src/main/browser/browser-client-page-execution-host-supersession.test.ts b/src/main/browser/browser-client-page-execution-host-supersession.test.ts index 773b5bf69c1..9f2b474f6ee 100644 --- a/src/main/browser/browser-client-page-execution-host-supersession.test.ts +++ b/src/main/browser/browser-client-page-execution-host-supersession.test.ts @@ -71,6 +71,7 @@ function createHarness() { const dependencies = { orcaProfileId: 'orca-profile-a', authorityConnectionIdentity: 'authority-record-a', + pairingAuthorityConnectionIdentity: 'pairing-authority-record-a', legacyAuthorityConnectionIdentity: 'legacy-authority-record-a', storageScope: 'a'.repeat(64), retainNetworkRoute: vi.fn(async (executionHostKey: string) => ({ diff --git a/src/main/browser/browser-client-page-reconciliation-adapters.test.ts b/src/main/browser/browser-client-page-reconciliation-adapters.test.ts index 38c95298b08..3244621c29c 100644 --- a/src/main/browser/browser-client-page-reconciliation-adapters.test.ts +++ b/src/main/browser/browser-client-page-reconciliation-adapters.test.ts @@ -118,6 +118,7 @@ function createHarness() { const dependencies = { orcaProfileId: 'orca-profile-a', authorityConnectionIdentity: 'authority-record-a', + pairingAuthorityConnectionIdentity: 'pairing-authority-record-a', legacyAuthorityConnectionIdentity: 'legacy-authority-record-a', storageScope: 'a'.repeat(64), retainNetworkRoute: vi.fn(async () => route), diff --git a/src/main/browser/browser-client-page-unavailability.test.ts b/src/main/browser/browser-client-page-unavailability.test.ts index 53f08d20046..83dd1bc2dbb 100644 --- a/src/main/browser/browser-client-page-unavailability.test.ts +++ b/src/main/browser/browser-client-page-unavailability.test.ts @@ -69,6 +69,7 @@ function harness() { const executor = new BrowserClientPageCommandExecutor({ orcaProfileId: 'profile-a', authorityConnectionIdentity: 'authority-a', + pairingAuthorityConnectionIdentity: 'pairing-authority-a', legacyAuthorityConnectionIdentity: 'legacy-authority-a', storageScope: 'a'.repeat(64), retainNetworkRoute: vi.fn(async () => ({ diff --git a/src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts b/src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts index abc576b68af..12faf553d9c 100644 --- a/src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts +++ b/src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts @@ -137,6 +137,7 @@ async function importPartition(): Promise { // Why: mirrors startPairedRuntimeBrowserClientHost — settings-level operations target the // server's own machine, so the identity comes from the same helper the host uses. executionHostIdentity: browserAuthorityExecutionHostStorageIdentity(storageScope), + pairingAuthorityConnectionIdentity: 'paired-runtime:pairing-authority-a', legacyAuthorityConnectionIdentity: 'paired-runtime:legacy-authority-a', legacyExecutionHostIdentity: legacyBrowserNativeExecutionHostStorageIdentity(authorityRuntimeId), diff --git a/src/main/browser/browser-client-route-cookie-import.test.ts b/src/main/browser/browser-client-route-cookie-import.test.ts index 161c421e939..fa3a33dcac4 100644 --- a/src/main/browser/browser-client-route-cookie-import.test.ts +++ b/src/main/browser/browser-client-route-cookie-import.test.ts @@ -54,6 +54,7 @@ const routeIdentity = { orcaProfileId: 'orca-profile-a', authorityConnectionIdentity: 'paired-runtime:authority-a', executionHostIdentity: 'execution-host-a', + pairingAuthorityConnectionIdentity: 'paired-runtime:pairing-authority-a', legacyAuthorityConnectionIdentity: 'paired-runtime:legacy-authority-a', legacyExecutionHostIdentity: 'legacy-execution-host-a', storageScope: 'e'.repeat(64) @@ -99,7 +100,8 @@ describe('importCookiesIntoClientRoutePartition', () => { expect(bindingStore.set).toHaveBeenCalledWith( partition, expect.stringMatching(/^[a-f0-9]{64}$/), - routeIdentity.storageScope + routeIdentity.storageScope, + expect.stringMatching(/^[a-f0-9]{64}$/) ) expect(recordClientRouteImportSourceMock).toHaveBeenCalledWith({ environmentId: 'env-a', @@ -187,6 +189,7 @@ describe('importCookiesIntoClientRoutePartition', () => { it('still succeeds when only the authority runtime id changed during the import', async () => { getRouteIdentityMock.mockReturnValueOnce(routeIdentity).mockReturnValue({ ...routeIdentity, + pairingAuthorityConnectionIdentity: 'paired-runtime:pairing-authority-restarted', legacyAuthorityConnectionIdentity: 'paired-runtime:legacy-authority-restarted', legacyExecutionHostIdentity: 'legacy-execution-host-restarted' }) diff --git a/src/main/browser/browser-client-route-cookie-import.ts b/src/main/browser/browser-client-route-cookie-import.ts index 63a17acb57b..edb6058394a 100644 --- a/src/main/browser/browser-client-route-cookie-import.ts +++ b/src/main/browser/browser-client-route-cookie-import.ts @@ -105,6 +105,12 @@ function bindRoutePartition( authorityConnectionIdentity: routeIdentity.authorityConnectionIdentity, executionHostIdentity: routeIdentity.executionHostIdentity }, + compatibleIdentity: { + orcaProfileId: routeIdentity.orcaProfileId, + browserProfileId, + authorityConnectionIdentity: routeIdentity.pairingAuthorityConnectionIdentity, + executionHostIdentity: routeIdentity.executionHostIdentity + }, legacyIdentity: { orcaProfileId: routeIdentity.orcaProfileId, browserProfileId, @@ -115,7 +121,12 @@ function bindRoutePartition( }) const persisted = bindings.get(derived.partition) if (persisted === null) { - bindings.set(derived.partition, derived.bindingFingerprint, routeIdentity.storageScope) + bindings.set( + derived.partition, + derived.bindingFingerprint, + routeIdentity.storageScope, + derived.aliasFingerprint + ) } else if (persisted !== derived.bindingFingerprint) { throw new Error('browser_route_partition_binding_conflict') } diff --git a/src/main/browser/browser-route-partition-binding-file.ts b/src/main/browser/browser-route-partition-binding-file.ts index 4214551b138..eef9ff59c7c 100644 --- a/src/main/browser/browser-route-partition-binding-file.ts +++ b/src/main/browser/browser-route-partition-binding-file.ts @@ -15,11 +15,24 @@ const FINGERPRINT_RE = /^[a-f0-9]{64}$/ * partition name can no longer be derived and is therefore always an orphan. * `lastUsedAt` orders capacity eviction; 0 marks an entry written before the * store recorded it, which is therefore evicted first. + * `aliasFingerprints` are newer identities adopted onto the partition. They sit beside + * `fingerprint` rather than replacing it, so an older build that reads only `fingerprint` + * still finds the same jar instead of reporting a binding conflict. */ export type BrowserRoutePartitionBinding = { fingerprint: string storageScope: string | null lastUsedAt: number + aliasFingerprints?: readonly string[] +} + +export const MAX_ALIAS_FINGERPRINTS = 2 + +export function bindingAnswersTo( + binding: BrowserRoutePartitionBinding, + fingerprint: string +): boolean { + return binding.fingerprint === fingerprint || !!binding.aliasFingerprints?.includes(fingerprint) } export function assertBinding(partition: string, fingerprint: string): void { @@ -111,13 +124,33 @@ function parseBinding(legacy: boolean, entry: unknown): BrowserRoutePartitionBin return null } const lastUsedAt = parseLastUsedAt(candidate.lastUsedAt) + const aliases = parseAliasFingerprints( + 'aliasFingerprints' in entry ? entry.aliasFingerprints : undefined + ) + const optional = aliases.length > 0 ? { aliasFingerprints: aliases } : {} if (candidate.storageScope === null) { - return { fingerprint: candidate.fingerprint, storageScope: null, lastUsedAt } + return { fingerprint: candidate.fingerprint, storageScope: null, lastUsedAt, ...optional } } if (typeof candidate.storageScope !== 'string' || !FINGERPRINT_RE.test(candidate.storageScope)) { return null } - return { fingerprint: candidate.fingerprint, storageScope: candidate.storageScope, lastUsedAt } + return { + fingerprint: candidate.fingerprint, + storageScope: candidate.storageScope, + lastUsedAt, + ...optional + } +} + +// Why lenient: an alias only widens lookup, so a malformed one is dropped rather than +// making the whole binding file unreadable. +function parseAliasFingerprints(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return [] + } + return value + .filter((alias): alias is string => typeof alias === 'string' && FINGERPRINT_RE.test(alias)) + .slice(-MAX_ALIAS_FINGERPRINTS) } // Why: absent on entries written before eviction existed, which must evict first. diff --git a/src/main/browser/browser-route-partition-binding-store-fake.ts b/src/main/browser/browser-route-partition-binding-store-fake.ts index 09343f5f687..9f45250028b 100644 --- a/src/main/browser/browser-route-partition-binding-store-fake.ts +++ b/src/main/browser/browser-route-partition-binding-store-fake.ts @@ -1,30 +1,45 @@ import type { BrowserRoutePartitionBindingStore } from './browser-route-session-registry-contract' export type BrowserRoutePartitionBindingStoreFake = BrowserRoutePartitionBindingStore & { - entries: Map + entries: Map } +type FakeBinding = { fingerprint: string; storageScope: string; aliasFingerprints?: string[] } + /** In-memory stand-in for the persisted binding store, for tests that only need its semantics. */ export function createBrowserRoutePartitionBindingStoreFake(): BrowserRoutePartitionBindingStoreFake { - const entries = new Map() + const entries = new Map() return { entries, get: (partition) => entries.get(partition)?.fingerprint ?? null, - set: (partition, fingerprint, storageScope) => { - entries.set(partition, { fingerprint, storageScope }) + set: (partition, fingerprint, storageScope, alias) => { + entries.set(partition, { + fingerprint, + storageScope, + ...(alias ? { aliasFingerprints: [alias] } : {}) + }) return [] }, touch: () => {}, findPartitionByFingerprint: (fingerprint) => { for (const [partition, entry] of entries) { - if (entry.fingerprint === fingerprint) { + if (entry.fingerprint === fingerprint || entry.aliasFingerprints?.includes(fingerprint)) { return partition } } return null }, - rebind: (partition, fingerprint, storageScope) => { - entries.set(partition, { fingerprint, storageScope }) + rebind: (partition, fingerprint, storageScope, alias) => { + const existing = entries.get(partition) + if (!existing) { + throw new Error('browser_route_partition_binding_invalid') + } + const aliases = (existing.aliasFingerprints ?? []).filter((entry) => entry !== fingerprint) + entries.set(partition, { + fingerprint, + storageScope, + aliasFingerprints: alias && !aliases.includes(alias) ? [...aliases, alias] : aliases + }) } } } diff --git a/src/main/browser/browser-route-partition-binding-store.test.ts b/src/main/browser/browser-route-partition-binding-store.test.ts index a317fcfe069..4ae4f1f69f3 100644 --- a/src/main/browser/browser-route-partition-binding-store.test.ts +++ b/src/main/browser/browser-route-partition-binding-store.test.ts @@ -228,4 +228,49 @@ describe('BrowserRoutePartitionBindingStore', () => { 'browser_route_partition_binding_invalid' ) }) + + it('answers to an alias beside the fingerprint older builds read', () => { + const store = new BrowserRoutePartitionBindingStore({ filePath: createPath() }) + store.set(partition, fingerprint, 'e'.repeat(64)) + + store.rebind(partition, fingerprint, 'e'.repeat(64), 'b'.repeat(64)) + store.rebind(partition, fingerprint, 'e'.repeat(64), 'b'.repeat(64)) + + expect(store.findPartitionByFingerprint('b'.repeat(64))).toBe(partition) + expect(store.findPartitionByFingerprint(fingerprint)).toBe(partition) + // Why: older builds read only `fingerprint`, so an alias must leave it in place. + expect(store.get(partition)).toBe(fingerprint) + expect(store.listBindings().get(partition)?.aliasFingerprints).toEqual(['b'.repeat(64)]) + store.set(partition, fingerprint, 'f'.repeat(64)) + expect(store.findPartitionByFingerprint('b'.repeat(64))).toBe(partition) + }) + + it('keeps aliases bounded and drops malformed ones without rejecting the file', () => { + const filePath = createPath() + writeFileSync( + filePath, + JSON.stringify({ + version: 2, + bindings: { + [partition]: { + fingerprint, + storageScope: 'e'.repeat(64), + lastUsedAt: 1, + aliasFingerprints: ['not-a-fingerprint', 7, 'b'.repeat(64)] + } + } + }) + ) + const store = new BrowserRoutePartitionBindingStore({ filePath }) + expect(store.listBindings().get(partition)?.aliasFingerprints).toEqual(['b'.repeat(64)]) + + store.rebind(partition, fingerprint, 'e'.repeat(64), 'c'.repeat(64)) + store.rebind(partition, fingerprint, 'e'.repeat(64), 'd'.repeat(64)) + + expect(store.listBindings().get(partition)?.aliasFingerprints).toEqual([ + 'c'.repeat(64), + 'd'.repeat(64) + ]) + expect(store.findPartitionByFingerprint('b'.repeat(64))).toBeNull() + }) }) diff --git a/src/main/browser/browser-route-partition-binding-store.ts b/src/main/browser/browser-route-partition-binding-store.ts index 59f9eb6dd44..c84f104616b 100644 --- a/src/main/browser/browser-route-partition-binding-store.ts +++ b/src/main/browser/browser-route-partition-binding-store.ts @@ -6,6 +6,8 @@ import { assertBinding, assertStorageScope, BINDING_STORE_VERSION, + bindingAnswersTo, + MAX_ALIAS_FINGERPRINTS, parseBindings, readBoundedUtf8File, type BrowserRoutePartitionBinding @@ -75,28 +77,45 @@ export class BrowserRoutePartitionBindingStore { return removed } - /** Partition already bound to `fingerprint`: where that identity's storage already lives. */ + /** Partition already bound to `fingerprint` or aliased to it: where that identity's storage lives. */ findPartitionByFingerprint(fingerprint: string): string | null { for (const [partition, binding] of Object.entries(this.load().bindings)) { - if (binding.fingerprint === fingerprint) { + if (bindingAnswersTo(binding, fingerprint)) { return partition } } return null } - /** Re-points a bound partition at a new fingerprint, so an identity-scheme change keeps its storage. */ - rebind(partition: string, fingerprint: string, storageScope: string): void { + /** + * Re-points a bound partition at a new fingerprint, so an identity-scheme change keeps its + * storage. `alias` lets it also answer to a newer identity that older builds cannot derive. + */ + rebind(partition: string, fingerprint: string, storageScope: string, alias?: string): void { assertBinding(partition, fingerprint) assertStorageScope(storageScope) + if (alias !== undefined) { + assertBinding(partition, alias) + } const state = this.load() - if (state.bindings[partition] === undefined) { + const existing = state.bindings[partition] + if (existing === undefined) { throw new Error('browser_route_partition_binding_invalid') } + const kept = (existing.aliasFingerprints ?? []).filter((entry) => entry !== fingerprint) + const aliases = + alias === undefined || alias === fingerprint || kept.includes(alias) + ? kept + : [...kept, alias].slice(-MAX_ALIAS_FINGERPRINTS) this.persist({ bindings: { ...state.bindings, - [partition]: { fingerprint, storageScope, lastUsedAt: Date.now() } + [partition]: { + fingerprint, + storageScope, + lastUsedAt: Date.now(), + ...(aliases.length > 0 ? { aliasFingerprints: aliases } : {}) + } } }) } @@ -116,9 +135,17 @@ export class BrowserRoutePartitionBindingStore { * Returns the evicted partitions: their metadata is gone, so the caller owns * destroying their storage. Capacity throws only when every binding is retained. */ - set(partition: string, fingerprint: string, storageScope: string): readonly string[] { + set( + partition: string, + fingerprint: string, + storageScope: string, + alias?: string + ): readonly string[] { assertBinding(partition, fingerprint) assertStorageScope(storageScope) + if (alias !== undefined) { + assertBinding(partition, alias) + } const state = this.load() this.assertMetadataPrecedesPartitionData(partition, state) const existing = state.bindings[partition] @@ -139,7 +166,17 @@ export class BrowserRoutePartitionBindingStore { delete bindings[victim] evicted.push(victim) } - bindings[partition] = { fingerprint, storageScope, lastUsedAt: Date.now() } + const kept = existing?.aliasFingerprints ?? [] + const aliases = + alias === undefined || alias === fingerprint || kept.includes(alias) + ? kept + : [...kept, alias].slice(-MAX_ALIAS_FINGERPRINTS) + bindings[partition] = { + fingerprint, + storageScope, + lastUsedAt: Date.now(), + ...(aliases.length > 0 ? { aliasFingerprints: aliases } : {}) + } this.persist({ bindings }) return evicted } diff --git a/src/main/browser/browser-route-partition-migration.test.ts b/src/main/browser/browser-route-partition-migration.test.ts index e5d37245eba..196b22de02a 100644 --- a/src/main/browser/browser-route-partition-migration.test.ts +++ b/src/main/browser/browser-route-partition-migration.test.ts @@ -185,4 +185,31 @@ describe('route partition identity migration', () => { expect(prepared).toBe(deriveBrowserRoutePartition(identity).partition) expect(bindings(filePath).get(derivedOther.partition)?.fingerprint).toBe('c'.repeat(64)) }) + + // Why: a re-pair re-points the open jar's binding at the new pairing; pages the new pairing + // opens before the old ones are destroyed must join that jar, not report a conflict. + it('lets a re-paired server open pages in its jar while the old pages are still open', async () => { + const registry = createSessionRegistry(createStorePath()) + const pairingAt = (revision: number) => ({ + ...identity, + authorityConnectionIdentity: `paired-runtime:pairing-${revision}` + }) + const open = (revision: number, browserPageId: string) => + registry.preparePage({ + identity, + compatibleIdentity: pairingAt(revision), + storageScope, + browserPageId, + pageHostGeneration: revision, + rendererWebContentsId: 11, + proxyEndpoint: { host: '127.0.0.1', port: 43123 } + }) + + const before = await open(1, 'page-before') + const after = await open(2, 'page-after') + + expect(after.partition).toBe(before.partition) + before.release() + after.release() + }) }) diff --git a/src/main/browser/browser-route-partition-migration.ts b/src/main/browser/browser-route-partition-migration.ts index 02ef7855db3..ebfc5492786 100644 --- a/src/main/browser/browser-route-partition-migration.ts +++ b/src/main/browser/browser-route-partition-migration.ts @@ -7,50 +7,94 @@ import { export type BrowserRoutePartitionMigrationStore = { get(partition: string): string | null findPartitionByFingerprint(fingerprint: string): string | null - rebind(partition: string, fingerprint: string, storageScope: string): void + rebind(partition: string, fingerprint: string, storageScope: string, alias?: string): void } export type BrowserRoutePartitionMigration = { bindings: BrowserRoutePartitionMigrationStore identity: BrowserRoutePartitionIdentity + /** + * Identity the shipping build derives for the same route, when it differs from `identity`. + * Every binding keeps this as its fingerprint, so a downgraded build still opens the jar. + */ + compatibleIdentity?: BrowserRoutePartitionIdentity /** Identity of the superseded scheme, or null when there is nothing to adopt. */ legacyIdentity: BrowserRoutePartitionIdentity | null storageScope: string derivePartition?: (identity: BrowserRoutePartitionIdentity) => DerivedBrowserRoutePartition } +/** A resolved partition; a new binding also records `aliasFingerprint` beside its fingerprint. */ +export type ResolvedBrowserRoutePartition = DerivedBrowserRoutePartition & { + aliasFingerprint?: string +} + /** * Partition that holds `identity`'s browser storage, adopting an older name for it. * - * The superseded identity hashed the paired server's per-process `runtimeId`, so - * deriving afresh would hand an upgraded client an empty partition and log the - * user out of every site. A binding already minted from `legacyIdentity` is - * re-pointed at the durable fingerprint instead, and keeps its cookies. Once - * adopted, the fingerprint alone resolves the partition, so the legacy name is - * never needed again -- including after the server restarts. + * Superseded identities hashed values that change without the server changing -- its + * per-process `runtimeId`, or a re-paired server's pairing revision -- so deriving afresh + * would hand the user an empty partition and log them out of every site. A binding already + * minted from an older identity is adopted instead and keeps its cookies. + * + * When `compatibleIdentity` differs, the binding's fingerprint follows it and `identity` + * rides along as an alias: the shipping build reads only the fingerprint, so a downgrade at + * any pairing reopens the same jar. The returned fingerprint is always the binding's own. */ export function resolveBrowserRoutePartitionBinding( migration: BrowserRoutePartitionMigration -): DerivedBrowserRoutePartition { +): ResolvedBrowserRoutePartition { const { bindings, legacyIdentity, storageScope } = migration const derive = migration.derivePartition ?? deriveBrowserRoutePartition const derived = derive(migration.identity) + const compatible = migration.compatibleIdentity ? derive(migration.compatibleIdentity) : derived + const alias = + compatible.bindingFingerprint === derived.bindingFingerprint + ? undefined + : derived.bindingFingerprint + // Re-points `partition` at the current pairing, unless an older build's jar already holds it. + const adopt = (partition: string): ResolvedBrowserRoutePartition => { + const holder = bindings.findPartitionByFingerprint(compatible.bindingFingerprint) + const fingerprint = + holder === null || holder === partition + ? compatible.bindingFingerprint + : (bindings.get(partition) ?? compatible.bindingFingerprint) + bindings.rebind(partition, fingerprint, storageScope, alias) + return { partition, bindingFingerprint: fingerprint } + } const bound = bindings.findPartitionByFingerprint(derived.bindingFingerprint) if (bound !== null) { - return { partition: bound, bindingFingerprint: derived.bindingFingerprint } + return alias && bindings.get(bound) !== compatible.bindingFingerprint + ? adopt(bound) + : { partition: bound, bindingFingerprint: bindings.get(bound) ?? derived.bindingFingerprint } } - if (legacyIdentity === null || bindings.get(derived.partition) !== null) { - return derived + if (bindings.get(derived.partition) !== null) { + // Why: only this identity derives that name, so a binding there is its own jar whose alias + // an older build's rewrite dropped. + return alias ? adopt(derived.partition) : derived } - const legacy = derive(legacyIdentity) - // Why: adopt only a partition this exact legacy identity minted -- any other binding - // on that name belongs to a different route and must keep its own storage. - if ( - legacy.partition === derived.partition || - bindings.get(legacy.partition) !== legacy.bindingFingerprint - ) { - return derived + const compatibleBound = + alias && bindings.findPartitionByFingerprint(compatible.bindingFingerprint) + if (compatibleBound) { + return adopt(compatibleBound) } - bindings.rebind(legacy.partition, derived.bindingFingerprint, storageScope) - return { partition: legacy.partition, bindingFingerprint: derived.bindingFingerprint } + if (legacyIdentity !== null) { + const legacy = derive(legacyIdentity) + // Why: adopt only a partition this exact legacy identity minted -- any other binding + // on that name belongs to a different route and must keep its own storage. + if ( + legacy.partition !== derived.partition && + bindings.get(legacy.partition) === legacy.bindingFingerprint + ) { + bindings.rebind(legacy.partition, compatible.bindingFingerprint, storageScope, alias) + return { partition: legacy.partition, bindingFingerprint: compatible.bindingFingerprint } + } + } + return alias + ? { + partition: derived.partition, + bindingFingerprint: compatible.bindingFingerprint, + aliasFingerprint: alias + } + : derived } diff --git a/src/main/browser/browser-route-partition-storage-retirement.test.ts b/src/main/browser/browser-route-partition-storage-retirement.test.ts index 6f87d42d616..3bddf13a5ae 100644 --- a/src/main/browser/browser-route-partition-storage-retirement.test.ts +++ b/src/main/browser/browser-route-partition-storage-retirement.test.ts @@ -108,6 +108,7 @@ describe('browser route partition storage retirement', () => { pairingRevision: number authorityRuntimeId: string authorityConnectionIdentity: string + pairingAuthorityConnectionIdentity: string legacyAuthorityConnectionIdentity: string }>({ createComposition: () => @@ -117,6 +118,7 @@ describe('browser route partition storage retirement', () => { pairingRevision: 1, authorityRuntimeId: 'runtime-a', authorityConnectionIdentity: 'authority-a', + pairingAuthorityConnectionIdentity: 'pairing-authority-a', legacyAuthorityConnectionIdentity: 'legacy-authority-a' }, createRoutes: () => stubRoutes(), @@ -134,6 +136,7 @@ describe('browser route partition storage retirement', () => { pairingRevision: 1, authorityRuntimeId: 'runtime-a', authorityConnectionIdentity: 'authority-a', + pairingAuthorityConnectionIdentity: 'pairing-authority-a', legacyAuthorityConnectionIdentity: 'legacy-authority-a' }) diff --git a/src/main/browser/browser-route-session-partition-binding.ts b/src/main/browser/browser-route-session-partition-binding.ts index f9ba04f4071..d95adb84655 100644 --- a/src/main/browser/browser-route-session-partition-binding.ts +++ b/src/main/browser/browser-route-session-partition-binding.ts @@ -2,7 +2,10 @@ import type { BrowserRoutePartitionIdentity, DerivedBrowserRoutePartition } from './browser-route-identity' -import { resolveBrowserRoutePartitionBinding } from './browser-route-partition-migration' +import { + resolveBrowserRoutePartitionBinding, + type ResolvedBrowserRoutePartition +} from './browser-route-partition-migration' import type { BrowserRouteSessionRegistryDependencies } from './browser-route-session-registry-contract' import type { BrowserRoutePreparePageInput } from './browser-route-session-state' @@ -11,10 +14,11 @@ export function resolveBrowserRouteSessionPartition( dependencies: BrowserRouteSessionRegistryDependencies, input: BrowserRoutePreparePageInput, derivePartition: (identity: BrowserRoutePartitionIdentity) => DerivedBrowserRoutePartition -): DerivedBrowserRoutePartition { +): ResolvedBrowserRoutePartition { const derived = resolveBrowserRoutePartitionBinding({ bindings: dependencies.bindingStore, identity: input.identity, + ...(input.compatibleIdentity ? { compatibleIdentity: input.compatibleIdentity } : {}), legacyIdentity: input.legacyIdentity ?? null, storageScope: input.storageScope, derivePartition @@ -34,7 +38,7 @@ export function resolveBrowserRouteSessionPartition( */ export function persistBrowserRouteSessionBinding( dependencies: BrowserRouteSessionRegistryDependencies, - derived: DerivedBrowserRoutePartition, + derived: ResolvedBrowserRoutePartition, storageScope: string ): void { if (dependencies.bindingStore.get(derived.partition) !== null) { @@ -44,7 +48,8 @@ export function persistBrowserRouteSessionBinding( const evicted = dependencies.bindingStore.set( derived.partition, derived.bindingFingerprint, - storageScope + storageScope, + derived.aliasFingerprint ) if (evicted.length > 0) { dependencies.releaseEvictedPartitions?.(evicted) diff --git a/src/main/browser/browser-route-session-registry-contract.ts b/src/main/browser/browser-route-session-registry-contract.ts index a37836deec1..7041930d1cd 100644 --- a/src/main/browser/browser-route-session-registry-contract.ts +++ b/src/main/browser/browser-route-session-registry-contract.ts @@ -9,10 +9,15 @@ import type { BrowserRouteElectronSession } from './browser-route-session-policy export type BrowserRoutePartitionBindingStore = { get(partition: string): string | null /** Returns partitions evicted to make room; their storage is now the caller's to destroy. */ - set(partition: string, fingerprint: string, storageScope: string): readonly string[] + set( + partition: string, + fingerprint: string, + storageScope: string, + alias?: string + ): readonly string[] touch(partition: string): void findPartitionByFingerprint(fingerprint: string): string | null - rebind(partition: string, fingerprint: string, storageScope: string): void + rebind(partition: string, fingerprint: string, storageScope: string, alias?: string): void } export type BrowserRouteSessionRegistryDependencies = { diff --git a/src/main/browser/browser-route-session-registry.ts b/src/main/browser/browser-route-session-registry.ts index d5ce0185404..a33cffa1514 100644 --- a/src/main/browser/browser-route-session-registry.ts +++ b/src/main/browser/browser-route-session-registry.ts @@ -236,7 +236,12 @@ export class BrowserRouteSessionRegistry { proxyEndpoint: ProxyEndpoint ): void { if (state.bindingFingerprint !== derived.bindingFingerprint) { - throw new Error('browser_route_partition_binding_conflict') + // Why: a re-paired server's binding follows its new pairing while its jar is still open; + // the persisted binding, not the fingerprint seen at open, decides who owns the jar. + if (this.dependencies.bindingStore.get(derived.partition) !== derived.bindingFingerprint) { + throw new Error('browser_route_partition_binding_conflict') + } + state.bindingFingerprint = derived.bindingFingerprint } if (!sameBrowserRouteProxyEndpoint(state.proxyEndpoint, proxyEndpoint)) { throw new Error('browser_route_partition_proxy_retarget') diff --git a/src/main/browser/browser-route-session-runtime.ts b/src/main/browser/browser-route-session-runtime.ts index 76f712fdb9d..b403caa689d 100644 --- a/src/main/browser/browser-route-session-runtime.ts +++ b/src/main/browser/browser-route-session-runtime.ts @@ -30,13 +30,17 @@ const currentBindingStore = (): BrowserRoutePartitionBindingStore => const bindingStore = { get: (partition: string): string | null => currentBindingStore().get(partition), - set: (partition: string, fingerprint: string, storageScope: string): readonly string[] => - currentBindingStore().set(partition, fingerprint, storageScope), + set: ( + partition: string, + fingerprint: string, + storageScope: string, + alias?: string + ): readonly string[] => currentBindingStore().set(partition, fingerprint, storageScope, alias), touch: (partition: string): void => currentBindingStore().touch(partition), findPartitionByFingerprint: (fingerprint: string): string | null => currentBindingStore().findPartitionByFingerprint(fingerprint), - rebind: (partition: string, fingerprint: string, storageScope: string): void => - currentBindingStore().rebind(partition, fingerprint, storageScope) + rebind: (partition: string, fingerprint: string, storageScope: string, alias?: string): void => + currentBindingStore().rebind(partition, fingerprint, storageScope, alias) } export const browserRouteSessionRegistry = new BrowserRouteSessionRegistry({ diff --git a/src/main/browser/browser-route-session-state.ts b/src/main/browser/browser-route-session-state.ts index 16dc7a438e5..515ba5cc19b 100644 --- a/src/main/browser/browser-route-session-state.ts +++ b/src/main/browser/browser-route-session-state.ts @@ -15,6 +15,8 @@ export type BrowserRouteSessionHandle = Readonly<{ export type BrowserRoutePreparePageInput = Readonly<{ profileMetadata?: BrowserClientProfileMetadata identity: BrowserRoutePartitionIdentity + /** What the shipping build derives for this route, when it differs from `identity`. */ + compatibleIdentity?: BrowserRoutePartitionIdentity /** Pre-migration identity, so an existing partition keeps serving this route. */ legacyIdentity?: BrowserRoutePartitionIdentity storageScope: string diff --git a/src/main/browser/paired-runtime-browser-authority-identity.ts b/src/main/browser/paired-runtime-browser-authority-identity.ts new file mode 100644 index 00000000000..ee4ca46a9c1 --- /dev/null +++ b/src/main/browser/paired-runtime-browser-authority-identity.ts @@ -0,0 +1,102 @@ +import { createHash } from 'node:crypto' +import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection' +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { BrowserClientPageAuthorityIdentity } from './browser-client-page-command-executor-dependencies' + +/** Authority identities a paired server's client-hosted pages derive their partitions from. */ +export function derivePairedRuntimeBrowserAuthorityIdentity(input: { + orcaProfileId: string + environment: Pick + pairingRevision: number + authorityRuntimeId: string + connection: PairedRuntimeConnection +}): BrowserClientPageAuthorityIdentity { + const { orcaProfileId, environment, pairingRevision, connection } = input + const pairingIdentity = pairingAuthorityConnectionIdentity( + orcaProfileId, + environment.id, + pairingRevision, + connection + ) + return { + // Why: Orca re-pairs its own deployments (managed SSH, WSL) in place on updates and + // refusals, so their jar must outlive the pairing; it still ends with the server's key. + authorityConnectionIdentity: environment.orcadDeployment + ? deployedAuthorityConnectionIdentity(orcaProfileId, environment.id, connection) + : pairingIdentity, + pairingAuthorityConnectionIdentity: pairingIdentity, + legacyAuthorityConnectionIdentity: legacyAuthorityConnectionIdentity( + orcaProfileId, + environment.id, + pairingRevision, + input.authorityRuntimeId, + connection + ) + } +} + +/** + * Storage identity of the connection to a paired server. + * + * Every component is a durable client-side record: the environment, its pairing + * revision, and the server's public key. The server's `authorityRuntimeId` is + * deliberately absent -- it is a per-process UUID, and hashing it here minted a + * fresh partition on every remote restart, dropping the user's cookies. + */ +function pairingAuthorityConnectionIdentity( + orcaProfileId: string, + environmentId: string, + pairingRevision: number, + connection: PairedRuntimeConnection +): string { + return connectionIdentityDigest([ + 'paired-runtime-browser', + orcaProfileId, + environmentId, + pairingRevision, + connection.hostPublicKeyB64, + connection.pairedDeviceId ?? null + ]) +} + +/** + * Storage identity of a server Orca deployed and re-pairs in place. + * + * Pairing revision and device id rotate on every re-pair, so only the environment and the + * server's public key remain: a server with a different key never reaches this jar. + */ +function deployedAuthorityConnectionIdentity( + orcaProfileId: string, + environmentId: string, + connection: PairedRuntimeConnection +): string { + return connectionIdentityDigest([ + 'paired-runtime-browser-deployed', + orcaProfileId, + environmentId, + connection.hostPublicKeyB64 + ]) +} + +/** Superseded per-process identity, kept only so its partition can be adopted. */ +function legacyAuthorityConnectionIdentity( + orcaProfileId: string, + environmentId: string, + pairingRevision: number, + authorityRuntimeId: string, + connection: PairedRuntimeConnection +): string { + return connectionIdentityDigest([ + 'paired-runtime-browser', + orcaProfileId, + environmentId, + pairingRevision, + authorityRuntimeId, + connection.hostPublicKeyB64, + connection.pairedDeviceId ?? null + ]) +} + +function connectionIdentityDigest(components: readonly unknown[]): string { + return `paired-runtime:${createHash('sha256').update(JSON.stringify(components)).digest('hex')}` +} diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts index 7c9a6a18a6d..7749ae3633d 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts @@ -27,10 +27,12 @@ const replacementAuthority: BrowserClientHostLeaseAuthority = { const initialInput = { authorityConnectionIdentity: 'authority-record-a', + pairingAuthorityConnectionIdentity: 'pairing-authority-record-a', legacyAuthorityConnectionIdentity: 'legacy-authority-record-a' } const replacementInput = { authorityConnectionIdentity: 'authority-record-b', + pairingAuthorityConnectionIdentity: 'pairing-authority-record-b', legacyAuthorityConnectionIdentity: 'legacy-authority-record-b' } diff --git a/src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts b/src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts index 9f82967182a..24a9002e24c 100644 --- a/src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts @@ -1,6 +1,11 @@ +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { + KnownRuntimeEnvironment, + OrcadDeploymentLink +} from '../../shared/runtime-environments' vi.mock('electron', () => ({ app: { getPath: () => tmpdir() }, @@ -40,15 +45,38 @@ vi.mock('./paired-runtime-browser-client-host-composition', () => ({ } })) +import { deriveBrowserRoutePartition } from './browser-route-identity' +import { BrowserRoutePartitionBindingStore } from './browser-route-partition-binding-store' +import { resolveBrowserRoutePartitionBinding } from './browser-route-partition-migration' import { configurePairedRuntimeBrowserClientHostsForOrcaProfile, getPairedRuntimeBrowserClientRouteIdentity, - startPairedRuntimeBrowserClientHost + startPairedRuntimeBrowserClientHost, + type ClientHostRouteIdentity } from './paired-runtime-browser-client-host-runtime' +const SSH_DEPLOYMENT: OrcadDeploymentLink = { + sshTargetId: 'target-a', + sshTargetGeneration: 1, + localPort: 9999, + remotePort: 7777 +} +const WSL_DEPLOYMENT: OrcadDeploymentLink = { + kind: 'wsl', + distro: 'Ubuntu', + localPort: 9999, + remotePort: 7777 +} + function pairedEnvironment( id: string, - overrides: { pairingRevision?: number; publicKeyB64?: string } = {} + overrides: { + pairingRevision?: number + publicKeyB64?: string + pairedDeviceId?: string + deviceToken?: string + orcadDeployment?: OrcadDeploymentLink + } = {} ): KnownRuntimeEnvironment { return { id, @@ -60,34 +88,165 @@ function pairedEnvironment( ...(overrides.pairingRevision === undefined ? {} : { pairingRevision: overrides.pairingRevision }), + ...(overrides.pairedDeviceId === undefined ? {} : { pairedDeviceId: overrides.pairedDeviceId }), + ...(overrides.orcadDeployment === undefined + ? {} + : { orcadDeployment: overrides.orcadDeployment }), preferredEndpointId: 'endpoint-a', endpoints: [ { id: 'endpoint-a', + kind: 'websocket', + label: 'Endpoint A', endpoint: 'ws://127.0.0.1:9999', - deviceToken: 'token-a', + deviceToken: overrides.deviceToken ?? 'token-a', publicKeyB64: overrides.publicKeyB64 ?? 'key-a' } ] - } as KnownRuntimeEnvironment + } +} + +async function routeIdentity( + environment: KnownRuntimeEnvironment, + authorityRuntimeId: string +): Promise { + await startPairedRuntimeBrowserClientHost({ environment, authorityRuntimeId }) + const identity = getPairedRuntimeBrowserClientRouteIdentity(environment.id) + if (!identity) { + throw new Error('missing route identity') + } + return identity } /** Authority identity the client host records for `environment` under `authorityRuntimeId`. */ async function connectionIdentity( environment: KnownRuntimeEnvironment, authorityRuntimeId: string -): Promise<{ current: string; legacy: string }> { - await startPairedRuntimeBrowserClientHost({ environment, authorityRuntimeId }) - const identity = getPairedRuntimeBrowserClientRouteIdentity(environment.id) - if (!identity) { - throw new Error('missing route identity') - } +): Promise<{ current: string; pairing: string; legacy: string }> { + const identity = await routeIdentity(environment, authorityRuntimeId) return { current: identity.authorityConnectionIdentity, + pairing: identity.pairingAuthorityConnectionIdentity, legacy: identity.legacyAuthorityConnectionIdentity } } +function pageIdentity( + identity: ClientHostRouteIdentity, + authorityConnectionIdentity: string, + executionHostIdentity: string +) { + return { + orcaProfileId: identity.orcaProfileId, + browserProfileId: 'default', + authorityConnectionIdentity, + executionHostIdentity + } +} + +/** Partition a page for `identity` lands in, resolved the way page creation does it. */ +function resolvePartition(filePath: string, identity: ClientHostRouteIdentity): string { + const store = new BrowserRoutePartitionBindingStore({ filePath }) + const derived = resolveBrowserRoutePartitionBinding({ + bindings: store, + identity: pageIdentity( + identity, + identity.authorityConnectionIdentity, + identity.executionHostIdentity + ), + compatibleIdentity: pageIdentity( + identity, + identity.pairingAuthorityConnectionIdentity, + identity.executionHostIdentity + ), + legacyIdentity: pageIdentity( + identity, + identity.legacyAuthorityConnectionIdentity, + identity.legacyExecutionHostIdentity + ), + storageScope: identity.storageScope + }) + const persisted = store.get(derived.partition) + if (persisted === null) { + store.set( + derived.partition, + derived.bindingFingerprint, + identity.storageScope, + derived.aliasFingerprint + ) + } else if (persisted !== derived.bindingFingerprint) { + throw new Error('browser_route_partition_binding_conflict') + } + return derived.partition +} + +/** The shipping build: derives from the pairing identity and reads only `fingerprint`. */ +function resolvePartitionAsOlderBuild(filePath: string, identity: ClientHostRouteIdentity): string { + const derived = deriveBrowserRoutePartition( + pageIdentity( + identity, + identity.pairingAuthorityConnectionIdentity, + identity.executionHostIdentity + ) + ) + const persisted: { bindings: Record } = JSON.parse( + readFileSync(filePath, 'utf8') + ) + const bound = Object.entries(persisted.bindings).find( + ([, binding]) => binding.fingerprint === derived.bindingFingerprint + ) + if (bound) { + return bound[0] + } + if (persisted.bindings[derived.partition] !== undefined) { + throw new Error('browser_route_partition_binding_conflict') + } + return derived.partition +} + +/** An older build's rewrite: its parser keeps only the fields it knows. */ +function rewriteAsOlderBuild(filePath: string): void { + const persisted: { + version: number + bindings: Record + } = JSON.parse(readFileSync(filePath, 'utf8')) + const bindings = Object.fromEntries( + Object.entries(persisted.bindings).map(([partition, binding]) => [ + partition, + { + fingerprint: binding.fingerprint, + storageScope: binding.storageScope, + lastUsedAt: binding.lastUsedAt + } + ]) + ) + writeFileSync(filePath, JSON.stringify({ version: persisted.version, bindings })) +} + +/** Seeds the jar an older build minted from `identity`'s pairing. */ +function seedOlderBuildJar(filePath: string, identity: ClientHostRouteIdentity): string { + const derived = deriveBrowserRoutePartition( + pageIdentity( + identity, + identity.pairingAuthorityConnectionIdentity, + identity.executionHostIdentity + ) + ) + new BrowserRoutePartitionBindingStore({ filePath }).set( + derived.partition, + derived.bindingFingerprint, + identity.storageScope + ) + return derived.partition +} + +function bindingStorePath(): string { + return join( + realpathSync(mkdtempSync(join(realpathSync(tmpdir()), 'orca-re-pair-partition-'))), + 'bindings.json' + ) +} + beforeEach(() => { compositions.length = 0 }) @@ -128,6 +287,147 @@ describe('client host authority connection identity', () => { }) }) +describe('client host identity for a server Orca re-pairs in place', () => { + it.each([ + ['managed SSH', SSH_DEPLOYMENT], + ['WSL', WSL_DEPLOYMENT] + ])('%s keeps one identity across re-pairs and still separates keys', async (_, deployment) => { + configurePairedRuntimeBrowserClientHostsForOrcaProfile({ orcaProfileId: 'profile-a' }) + const id = `environment-${deployment.kind ?? 'ssh'}` + const before = await connectionIdentity( + pairedEnvironment(id, { orcadDeployment: deployment, pairedDeviceId: 'device-a' }), + 'runtime-a' + ) + const rePaired = await connectionIdentity( + pairedEnvironment(id, { + orcadDeployment: deployment, + pairingRevision: 5, + pairedDeviceId: 'device-b', + deviceToken: 'token-b' + }), + 'runtime-b' + ) + const otherKey = await connectionIdentity( + pairedEnvironment(id, { orcadDeployment: deployment, publicKeyB64: 'key-b' }), + 'runtime-a' + ) + const otherEnvironment = await connectionIdentity( + pairedEnvironment(`${id}-other`, { orcadDeployment: deployment }), + 'runtime-a' + ) + + expect(rePaired.current).toBe(before.current) + expect(rePaired.pairing).not.toBe(before.pairing) + expect(otherKey.current).not.toBe(before.current) + expect(otherEnvironment.current).not.toBe(before.current) + }) + + it('leaves a manually paired server on its pairing identity', async () => { + configurePairedRuntimeBrowserClientHostsForOrcaProfile({ orcaProfileId: 'profile-a' }) + const identity = await connectionIdentity(pairedEnvironment('environment-manual'), 'runtime-a') + + expect(identity.current).toBe(identity.pairing) + }) + + it.each([ + ['managed SSH', SSH_DEPLOYMENT], + ['WSL', WSL_DEPLOYMENT] + ])( + '%s keeps the jar an older build filled through re-pairs and a downgrade', + async (_, deployment) => { + configurePairedRuntimeBrowserClientHostsForOrcaProfile({ orcaProfileId: 'profile-a' }) + const filePath = bindingStorePath() + const id = `environment-jar-${deployment.kind ?? 'ssh'}` + const atRevisionOne = await routeIdentity( + pairedEnvironment(id, { orcadDeployment: deployment, pairedDeviceId: 'device-a' }), + 'runtime-a' + ) + const atRevisionTwo = await routeIdentity( + pairedEnvironment(id, { + orcadDeployment: deployment, + pairingRevision: 2, + pairedDeviceId: 'device-b', + deviceToken: 'token-b' + }), + 'runtime-b' + ) + const withNewKey = await routeIdentity( + pairedEnvironment(id, { + orcadDeployment: deployment, + pairingRevision: 3, + publicKeyB64: 'key-b' + }), + 'runtime-c' + ) + const olderJar = seedOlderBuildJar(filePath, atRevisionOne) + + expect(resolvePartition(filePath, atRevisionOne)).toBe(olderJar) + // Why: a downgraded build reads only `fingerprint`, so it must reopen the same jar. + expect(resolvePartitionAsOlderBuild(filePath, atRevisionOne)).toBe(olderJar) + expect(resolvePartition(filePath, atRevisionTwo)).toBe(olderJar) + // The fingerprint follows the pairing, so a downgrade after the re-pair still finds it. + expect(resolvePartitionAsOlderBuild(filePath, atRevisionTwo)).toBe(olderJar) + // An older build's rewrite drops the alias; the next upgrade re-adopts at that pairing. + rewriteAsOlderBuild(filePath) + expect(resolvePartition(filePath, atRevisionTwo)).toBe(olderJar) + // Why: a different server key is a different server, which never inherits the jar. + expect(resolvePartition(filePath, withNewKey)).not.toBe(olderJar) + } + ) + + it('mints a fresh jar an older build can open at every pairing', async () => { + configurePairedRuntimeBrowserClientHostsForOrcaProfile({ orcaProfileId: 'profile-a' }) + const filePath = bindingStorePath() + const id = 'environment-fresh' + const atRevisionOne = await routeIdentity( + pairedEnvironment(id, { orcadDeployment: SSH_DEPLOYMENT, pairedDeviceId: 'device-a' }), + 'runtime-a' + ) + const atRevisionTwo = await routeIdentity( + pairedEnvironment(id, { + orcadDeployment: SSH_DEPLOYMENT, + pairingRevision: 2, + pairedDeviceId: 'device-b' + }), + 'runtime-b' + ) + + const jar = resolvePartition(filePath, atRevisionOne) + expect(resolvePartitionAsOlderBuild(filePath, atRevisionOne)).toBe(jar) + expect(resolvePartition(filePath, atRevisionTwo)).toBe(jar) + expect(resolvePartitionAsOlderBuild(filePath, atRevisionTwo)).toBe(jar) + // Why: the jar's name is derived from the stable identity, so losing its alias to an + // older build's rewrite must not turn it into a binding conflict. + rewriteAsOlderBuild(filePath) + expect(resolvePartition(filePath, atRevisionOne)).toBe(jar) + expect(resolvePartitionAsOlderBuild(filePath, atRevisionOne)).toBe(jar) + }) + + it('adopts a per-process jar under the fingerprint an older build reads', async () => { + configurePairedRuntimeBrowserClientHostsForOrcaProfile({ orcaProfileId: 'profile-a' }) + const filePath = bindingStorePath() + const identity = await routeIdentity( + pairedEnvironment('environment-runtime-era', { orcadDeployment: SSH_DEPLOYMENT }), + 'runtime-a' + ) + const legacy = deriveBrowserRoutePartition( + pageIdentity( + identity, + identity.legacyAuthorityConnectionIdentity, + identity.legacyExecutionHostIdentity + ) + ) + new BrowserRoutePartitionBindingStore({ filePath }).set( + legacy.partition, + legacy.bindingFingerprint, + identity.storageScope + ) + + expect(resolvePartition(filePath, identity)).toBe(legacy.partition) + expect(resolvePartitionAsOlderBuild(filePath, identity)).toBe(legacy.partition) + }) +}) + describe('client host route identity lifetime', () => { it('stops answering with a route identity once the host is retired by its own error', async () => { configurePairedRuntimeBrowserClientHostsForOrcaProfile({ orcaProfileId: 'profile-a' }) diff --git a/src/main/browser/paired-runtime-browser-client-host-runtime.ts b/src/main/browser/paired-runtime-browser-client-host-runtime.ts index 0ba0ed271fb..e7bf6af09ab 100644 --- a/src/main/browser/paired-runtime-browser-client-host-runtime.ts +++ b/src/main/browser/paired-runtime-browser-client-host-runtime.ts @@ -36,29 +36,29 @@ import { type PairedRuntimeBrowserClientHostStart } from './paired-runtime-browser-client-host-registry' import { PairedRuntimeBrowserNetworkRoute } from './paired-runtime-browser-network-route' +import { derivePairedRuntimeBrowserAuthorityIdentity } from './paired-runtime-browser-authority-identity' +import type { BrowserClientPageAuthorityIdentity } from './browser-client-page-command-executor-dependencies' import { browserRouteSessionRegistry, browserRouteWebContentsRegistry } from './browser-route-session-runtime' -export type ClientHostRouteIdentity = { +export type ClientHostRouteIdentity = BrowserClientPageAuthorityIdentity & { orcaProfileId: string - authorityConnectionIdentity: string + /** Pairs with `authorityConnectionIdentity` and `pairingAuthorityConnectionIdentity`. */ executionHostIdentity: string - /** Pre-migration pair, naming the partition an older build already populated. */ - legacyAuthorityConnectionIdentity: string + /** Pairs with `legacyAuthorityConnectionIdentity`, naming the partition an older build populated. */ legacyExecutionHostIdentity: string storageScope: string } -type ProductionBrowserClientHostStart = PairedRuntimeBrowserClientHostStart & { - connection: PairedRuntimeConnection - orcaProfileId: string - authorityConnectionIdentity: string - legacyAuthorityConnectionIdentity: string - storageScope: string - environmentLabel: string -} +type ProductionBrowserClientHostStart = PairedRuntimeBrowserClientHostStart & + BrowserClientPageAuthorityIdentity & { + connection: PairedRuntimeConnection + orcaProfileId: string + storageScope: string + environmentLabel: string + } let activeOrcaProfileId: string | null = null /** Route identity of each live client host, for storage operations without a page. */ @@ -93,6 +93,7 @@ const browserClientHosts = executor = new BrowserClientPageCommandExecutor({ orcaProfileId: next.orcaProfileId, authorityConnectionIdentity: next.authorityConnectionIdentity, + pairingAuthorityConnectionIdentity: next.pairingAuthorityConnectionIdentity, legacyAuthorityConnectionIdentity: next.legacyAuthorityConnectionIdentity, storageScope: next.storageScope, retainNetworkRoute, @@ -163,24 +164,19 @@ export async function startPairedRuntimeBrowserClientHost(options: { orcaProfileId, environmentId: options.environment.id }) + const authorityIdentity = derivePairedRuntimeBrowserAuthorityIdentity({ + orcaProfileId, + environment: options.environment, + pairingRevision, + authorityRuntimeId: options.authorityRuntimeId, + connection + }) const routeIdentity: ClientHostRouteIdentity = { orcaProfileId, storageScope, - authorityConnectionIdentity: authorityConnectionIdentity( - orcaProfileId, - options.environment.id, - pairingRevision, - connection - ), + ...authorityIdentity, // Why: settings-level operations target the server's own machine, not a nested SSH/WSL host. executionHostIdentity: browserAuthorityExecutionHostStorageIdentity(storageScope), - legacyAuthorityConnectionIdentity: legacyAuthorityConnectionIdentity( - orcaProfileId, - options.environment.id, - pairingRevision, - options.authorityRuntimeId, - connection - ), legacyExecutionHostIdentity: legacyBrowserNativeExecutionHostStorageIdentity( options.authorityRuntimeId ) @@ -193,8 +189,7 @@ export async function startPairedRuntimeBrowserClientHost(options: { orcaProfileId, storageScope: routeIdentity.storageScope, environmentLabel: options.environment.name, - authorityConnectionIdentity: routeIdentity.authorityConnectionIdentity, - legacyAuthorityConnectionIdentity: routeIdentity.legacyAuthorityConnectionIdentity + ...authorityIdentity }) clientHostRouteIdentities.set(options.environment.id, routeIdentity) return authority @@ -267,53 +262,6 @@ function createNetworkRoutes( }) } -/** - * Storage identity of the connection to a paired server. - * - * Every component is a durable client-side record: the environment, its pairing - * revision, and the server's public key. The server's `authorityRuntimeId` is - * deliberately absent -- it is a per-process UUID, and hashing it here minted a - * fresh partition on every remote restart, dropping the user's cookies. - */ -function authorityConnectionIdentity( - orcaProfileId: string, - environmentId: string, - pairingRevision: number, - connection: PairedRuntimeConnection -): string { - return connectionIdentityDigest([ - 'paired-runtime-browser', - orcaProfileId, - environmentId, - pairingRevision, - connection.hostPublicKeyB64, - connection.pairedDeviceId ?? null - ]) -} - -/** Superseded per-process identity, kept only so its partition can be adopted. */ -function legacyAuthorityConnectionIdentity( - orcaProfileId: string, - environmentId: string, - pairingRevision: number, - authorityRuntimeId: string, - connection: PairedRuntimeConnection -): string { - return connectionIdentityDigest([ - 'paired-runtime-browser', - orcaProfileId, - environmentId, - pairingRevision, - authorityRuntimeId, - connection.hostPublicKeyB64, - connection.pairedDeviceId ?? null - ]) -} - -function connectionIdentityDigest(components: readonly unknown[]): string { - return `paired-runtime:${createHash('sha256').update(JSON.stringify(components)).digest('hex')}` -} - // Why: staged remote bytes are main-owned scratch, never the user's visible Downloads folder. function browserClientFileStagingRoot(environmentId: string): string { const scope = createHash('sha256').update(environmentId).digest('hex').slice(0, 16)