mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user