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)