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.
This commit is contained in:
Neil
2026-10-20 19:00:06 -07:00
committed by GitHub
parent 1c76c71409
commit 60b8eb4a6f
29 changed files with 775 additions and 154 deletions
@@ -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. */
@@ -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()
@@ -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,
@@ -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')
@@ -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<void> | 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)
@@ -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 () => ({
@@ -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<RetainedNetworkRoute>
retireSupersededExecutionHostPages(route: RetainedNetworkRoute): Promise<void>
@@ -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,
@@ -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) => ({
@@ -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),
@@ -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 () => ({
@@ -137,6 +137,7 @@ async function importPartition(): Promise<string> {
// 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),
@@ -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'
})
@@ -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')
}
@@ -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.
@@ -1,30 +1,45 @@
import type { BrowserRoutePartitionBindingStore } from './browser-route-session-registry-contract'
export type BrowserRoutePartitionBindingStoreFake = BrowserRoutePartitionBindingStore & {
entries: Map<string, { fingerprint: string; storageScope: string }>
entries: Map<string, FakeBinding>
}
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<string, { fingerprint: string; storageScope: string }>()
const entries = new Map<string, FakeBinding>()
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
})
}
}
}
@@ -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()
})
})
@@ -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
}
@@ -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()
})
})
@@ -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
}
@@ -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'
})
@@ -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)
@@ -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 = {
@@ -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')
@@ -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({
@@ -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
@@ -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<KnownRuntimeEnvironment, 'id' | 'orcadDeployment'>
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')}`
}
@@ -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'
}
@@ -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<ClientHostRouteIdentity> {
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<string, { fingerprint: string }> } = 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<string, { fingerprint: string; storageScope: string; lastUsedAt: number }>
} = 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' })
@@ -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)