fix(browser): retire SSH partitions when their profile is deleted (#35114)

* fix(browser): retire SSH partitions when their profile is deleted

* Keep profile deletion sequencing with retirement cleanup

* Keep profile delete best-effort when route partition retirement fails

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 14:42:01 -07:00
committed by GitHub
co-authored by OrcaWin
parent bb1b05e3bd
commit 85dfae514d
4 changed files with 348 additions and 13 deletions
@@ -29,8 +29,19 @@ export async function retireFailedBrowserSessionProfile(
/** Profile delete, after the registry has dropped it: unload extensions and clear its storage. */
export async function retireDeletedBrowserSessionProfile(
profile: BrowserSessionProfile
profile: BrowserSessionProfile,
beforeRetirement?: () => Promise<void> | undefined
): Promise<void> {
try {
await beforeRetirement?.()
} catch (error) {
// Why: the profile is already deleted and persisted, so route cleanup stays best-effort.
console.warn('[browser-session] Failed to retire route partitions for deleted profile', error)
}
await clearDeletedBrowserSessionProfile(profile)
}
async function clearDeletedBrowserSessionProfile(profile: BrowserSessionProfile): Promise<void> {
await detachBrowserExtensionsFromProfileSession(profile)
// Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind.
try {
+2 -1
View File
@@ -58,6 +58,7 @@ class BrowserSessionRegistry {
private defaultPartition = ORCA_BROWSER_PARTITION
// Why: profiles change from IPC, the CLI and managed-profile owners; open windows and paired clients only learn of it here.
onProfilesChanged: (() => void) | null = null
onProfileDeleted: ((profileId: string) => Promise<void>) | null = null
constructor() {
this.resetDefaultProfile()
@@ -294,7 +295,7 @@ class BrowserSessionRegistry {
this.persistMeta(
forgetBrowserSessionProfileMeta(this.loadPersistedMeta(), profile, this.defaultPartition)
)
await retireDeletedBrowserSessionProfile(profile)
await retireDeletedBrowserSessionProfile(profile, () => this.onProfileDeleted?.(profileId))
return true
}
@@ -38,6 +38,8 @@ type PreparedLocalSshPartition = {
type PendingLocalSshPartition = {
targetId: string
browserProfileId: string
storage: { partition: string | null }
promise: Promise<PreparedLocalSshPartition>
}
@@ -55,7 +57,16 @@ export function localSshBrowserAuthorityConnectionIdentity(orcaProfileId: string
}
export function isLocalSshBrowserPartition(partition: string): boolean {
return preparedByPartition.has(partition)
const prepared = preparedByPartition.get(partition)
if (!prepared) {
return false
}
try {
browserSessionRegistry.requireRouteBrowserProfile(prepared.browserProfileId)
return true
} catch {
return false
}
}
export function localSshBrowserPartitionForSession(webSession: Session): string | null {
@@ -74,6 +85,7 @@ export async function prepareLocalSshBrowserPartition(input: {
/** The error card's "Try anyway" bypasses the forwarding probe. */
skipProbe?: boolean
}): Promise<{ partition: string }> {
browserSessionRegistry.requireRouteBrowserProfile(input.browserProfileId)
// Keep skipped probes separate so "Check again" cannot reuse a bypassed success.
const identityKey = JSON.stringify([
input.targetId,
@@ -82,8 +94,14 @@ export async function prepareLocalSshBrowserPartition(input: {
])
let pending = preparedByIdentityKey.get(identityKey)
if (!pending) {
const promise = prepareFresh(input)
pending = { targetId: input.targetId, promise }
const storage: PendingLocalSshPartition['storage'] = { partition: null }
const promise = prepareFresh(input, storage)
pending = {
targetId: input.targetId,
browserProfileId: input.browserProfileId,
storage,
promise
}
preparedByIdentityKey.set(identityKey, pending)
promise.catch(() => {
if (preparedByIdentityKey.get(identityKey) === pending) {
@@ -92,14 +110,14 @@ export async function prepareLocalSshBrowserPartition(input: {
})
}
const prepared = await pending.promise
browserSessionRegistry.requireRouteBrowserProfile(input.browserProfileId)
return { partition: prepared.partition }
}
async function prepareFresh(input: {
targetId: string
browserProfileId: string
skipProbe?: boolean
}): Promise<PreparedLocalSshPartition> {
async function prepareFresh(
input: { targetId: string; browserProfileId: string; skipProbe?: boolean },
storage: PendingLocalSshPartition['storage']
): Promise<PreparedLocalSshPartition> {
const orcaProfileId = activeBrowserRoutePartitionOrcaProfileId()
if (!orcaProfileId) {
throw new Error('browser_local_route_profile_unavailable')
@@ -143,6 +161,7 @@ async function prepareFresh(input: {
} else {
bindings.touch(derived.partition)
}
storage.partition = derived.partition
await prepareBrowserRouteSessionPolicy({
partition: derived.partition,
browserProfileId: input.browserProfileId,
@@ -190,6 +209,14 @@ function ensureLocalSshWebRtcGuard(): void {
/** Stops guests and releases route records; the caller clears storage afterward. */
export async function releaseLocalSshBrowserPartitionsForTarget(targetId: string): Promise<void> {
await closeLocalSshBrowserRouteForTarget(targetId)
await releasePreparedPartitions((pending) => pending.targetId === targetId)
}
async function releasePreparedPartitions(
owns: (pending: PendingLocalSshPartition) => boolean,
released = new Set<string>()
): Promise<void> {
let failure: unknown
let contentsBySession: Map<Session, WebContents[]> | null = null
const invalidateContents = (): void => {
contentsBySession = null
@@ -197,11 +224,14 @@ export async function releaseLocalSshBrowserPartitionsForTarget(targetId: string
app.on('web-contents-created', invalidateContents)
try {
for (const [identityKey, pending] of preparedByIdentityKey) {
if (pending.targetId !== targetId) {
if (!owns(pending)) {
continue
}
const prepared = await pending.promise.catch(() => null)
if (prepared?.targetId === targetId) {
if (pending.storage.partition) {
released.add(pending.storage.partition)
}
if (prepared) {
preparedByIdentityKey.delete(identityKey)
preparedByPartition.delete(prepared.partition)
// Close guests before clearing storage so they cannot recreate cookies after bindings are removed.
@@ -221,12 +251,32 @@ export async function releaseLocalSshBrowserPartitionsForTarget(targetId: string
closeRouteGuest(contents)
}
}
browserSessionRegistry.clearRoutePartitionPolicies(prepared.partition)
try {
browserSessionRegistry.clearRoutePartitionPolicies(prepared.partition)
} catch (error) {
failure ??= error
}
}
}
} finally {
app.removeListener('web-contents-created', invalidateContents)
}
if (failure) {
throw failure
}
}
registerBrowserRoutePartitionRetentionProbe((partition) => preparedByPartition.has(partition))
// Registry stays routing-agnostic; this module installs its own profile-deletion hook.
browserSessionRegistry.onProfileDeleted = async (profileId) => {
const partitions = new Set<string>()
try {
await releasePreparedPartitions((pending) => pending.browserProfileId === profileId, partitions)
} finally {
await releaseEvictedBrowserRoutePartitionStorage(
[...partitions],
isBrowserRoutePartitionRetainedByAnyOwner
)
}
}
@@ -0,0 +1,273 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
type TestSession = {
partition: string
clearStorageData: ReturnType<typeof vi.fn>
clearCache: ReturnType<typeof vi.fn>
setProxy: ReturnType<typeof vi.fn>
closeAllConnections: ReturnType<typeof vi.fn>
resolveProxy: ReturnType<typeof vi.fn>
}
type TestGuest = { session: TestSession; isDestroyed(): boolean; close(): void }
const state = vi.hoisted(() => {
const directories: string[] = []
const guests: TestGuest[] = []
return {
directory: '',
directories,
sessions: new Map<string, TestSession>(),
guests,
retain: vi.fn(async () => ({ host: '127.0.0.1', port: 1080 })),
probe: vi.fn(async () => 'ok'),
closeTarget: vi.fn(async () => {}),
policy: vi.fn(async () => {}),
clearPolicy: vi.fn()
}
})
vi.mock('electron', async () => {
const { EventEmitter } = await import('node:events')
const { mkdtempSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
state.directory = mkdtempSync(join(tmpdir(), 'ssh-profile-retirement-'))
state.directories.push(state.directory)
return {
app: Object.assign(new EventEmitter(), { getPath: () => state.directory }),
session: {
fromPartition(partition: string) {
let session = state.sessions.get(partition)
if (!session) {
session = {
partition,
clearStorageData: vi.fn(async () => {}),
clearCache: vi.fn(async () => {}),
setProxy: vi.fn(async () => {}),
closeAllConnections: vi.fn(async () => {}),
resolveProxy: vi.fn(async () => 'SOCKS5 127.0.0.1:1080')
}
state.sessions.set(partition, session)
}
return session
}
},
webContents: { getAllWebContents: () => state.guests }
}
})
vi.mock('./browser-session-cookie-staging', () => ({
applyPendingBrowserCookieImports() {},
clearPendingBrowserCookieImport() {}
}))
vi.mock('./browser-session-partition-policies', () => ({
installBrowserSessionPartitionPolicies: state.policy,
clearBrowserSessionPartitionPolicies: state.clearPolicy,
forgetBrowserSessionPartitionConfiguration() {},
retireBrowserSessionUserAgentPolicy() {}
}))
vi.mock('./browser-session-proxy', () => ({ invalidateBrowserSessionProxyApplication() {} }))
vi.mock('../network/proxy-settings', () => ({ async retireProxySessionApplication() {} }))
vi.mock('./browser-webauthn-account-picker', () => ({
cancelBrowserWebAuthnAccountRequestsForSession() {}
}))
vi.mock('./extensions/browser-extension-session-loader', () => ({
attachBrowserExtensionsToProfileSession() {},
async detachBrowserExtensionsFromProfileSession() {}
}))
vi.mock('../persistence/loading-store/user-data-path', () => ({
getCanonicalUserDataPath: () => state.directory
}))
vi.mock('./browser-identity-mode-store', () => ({
async markBrowserIdentityMigrationNoticePending() {}
}))
vi.mock('./browser-site-permission-store', () => ({ forgetBrowserSitePermissions() {} }))
vi.mock('./browser-site-display-store', () => ({ forgetBrowserSiteDisplay() {} }))
vi.mock('./browser-route-webrtc-policy', () => ({ enforceBrowserRouteWebRtcPolicy() {} }))
vi.mock('./local-ssh-browser-route', () => ({
retainLocalSshBrowserRoute: state.retain,
probeLocalSshBrowserRouteForwarding: state.probe,
closeLocalSshBrowserRouteForTarget: state.closeTarget
}))
import { session } from 'electron'
import { browserSessionRegistry as registry } from './browser-session-registry'
import {
configureBrowserRoutePartitionBindingsForOrcaProfile,
currentBrowserRoutePartitionBindingStore
} from './browser-route-partition-binding-runtime'
import { isBrowserRoutePartitionRetainedByAnyOwner } from './browser-route-partition-retention'
import {
isLocalSshBrowserPartition,
prepareLocalSshBrowserPartition,
releaseLocalSshBrowserPartitionsForTarget
} from './local-ssh-browser-partitions'
beforeEach(() => {
state.directory = mkdtempSync(join(tmpdir(), 'ssh-profile-retirement-'))
state.directories.push(state.directory)
state.guests.length = 0
state.policy.mockReset().mockResolvedValue(undefined)
state.clearPolicy.mockReset()
state.retain.mockClear()
state.probe.mockReset().mockResolvedValue('ok')
state.closeTarget.mockClear()
const options = { orcaProfileId: 'default', profileDirectory: state.directory }
registry.configureForOrcaProfile(options)
configureBrowserRoutePartitionBindingsForOrcaProfile(options)
})
afterAll(() => {
for (const directory of state.directories) {
rmSync(directory, { recursive: true, force: true })
}
})
async function profile(label: string) {
const created = await registry.createProfile('isolated', label)
if (!created) {
throw new Error('Expected isolated profile')
}
return created
}
function guest(partition: string) {
const webSession = state.sessions.get(partition)
if (!webSession) {
throw new Error('Expected prepared session')
}
let destroyed = false
const guest = {
session: webSession,
isDestroyed: () => destroyed,
close: () => {
destroyed = true
}
}
state.guests.push(guest)
return guest
}
describe('deleted browser profile SSH partitions', () => {
it('closes its guests and clears every target once while preserving sibling routes', async () => {
const retired = await profile('Retired')
const sibling = await profile('Sibling')
const input = { targetId: 'shared-target', browserProfileId: retired.id }
const first = await prepareLocalSshBrowserPartition(input)
expect(await prepareLocalSshBrowserPartition({ ...input, skipProbe: true })).toEqual(first)
const second = await prepareLocalSshBrowserPartition({ ...input, targetId: 'other-target' })
const survivor = await prepareLocalSshBrowserPartition({
...input,
browserProfileId: sibling.id
})
const removed = [guest(first.partition), guest(second.partition)]
const live = guest(survivor.partition)
for (const contents of removed) {
contents.session.clearStorageData.mockImplementation(async () => {
expect(contents.isDestroyed()).toBe(true)
})
}
await expect(registry.deleteProfile(retired.id)).resolves.toBe(true)
expect(removed.every((contents) => contents.isDestroyed())).toBe(true)
for (const { partition } of [first, second]) {
expect(isLocalSshBrowserPartition(partition)).toBe(false)
expect(isBrowserRoutePartitionRetainedByAnyOwner(partition)).toBe(false)
expect(session.fromPartition(partition).clearStorageData).toHaveBeenCalledTimes(1)
expect(session.fromPartition(partition).clearCache).toHaveBeenCalledTimes(1)
expect(currentBrowserRoutePartitionBindingStore().get(partition)).toBeNull()
}
await expect(prepareLocalSshBrowserPartition(input)).rejects.toThrow('profile_unavailable')
expect(live.isDestroyed()).toBe(false)
expect(
await prepareLocalSshBrowserPartition({ ...input, browserProfileId: sibling.id })
).toEqual(survivor)
expect(session.fromPartition(survivor.partition).clearStorageData).not.toHaveBeenCalled()
expect(state.closeTarget).not.toHaveBeenCalled()
expect(session.fromPartition(retired.partition).clearStorageData).toHaveBeenCalledTimes(1)
await releaseLocalSshBrowserPartitionsForTarget(input.targetId)
})
it.each([false, true])(
'retires late native setup (failure=%s) before deletion completes',
async (failure) => {
const retired = await profile('Late')
const gate = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
state.policy.mockImplementationOnce(() => {
started.resolve()
return gate.promise
})
const input = { targetId: 'pending-target', browserProfileId: retired.id }
const pending = prepareLocalSshBrowserPartition(input)
const result = Promise.allSettled([pending])
await started.promise
const partition = [...currentBrowserRoutePartitionBindingStore().listBindings().keys()][0]
if (!partition) {
throw new Error('Expected pending binding')
}
let deleted = false
const deletion = registry.deleteProfile(retired.id).then(() => {
deleted = true
})
await expect(prepareLocalSshBrowserPartition(input)).rejects.toThrow('profile_unavailable')
expect(isLocalSshBrowserPartition(partition)).toBe(false)
expect(deleted).toBe(false)
if (failure) {
gate.reject(new Error('native setup failed'))
} else {
gate.resolve()
}
expect((await result)[0]?.status).toBe('rejected')
await deletion
expect(isBrowserRoutePartitionRetainedByAnyOwner(partition)).toBe(false)
expect(session.fromPartition(partition).clearStorageData).toHaveBeenCalledTimes(1)
expect(currentBrowserRoutePartitionBindingStore().get(partition)).toBeNull()
}
)
it('does not wait for another profile on the same target', async () => {
const retired = await profile('Ready')
const survivor = await profile('Pending sibling')
const ready = await prepareLocalSshBrowserPartition({
targetId: 'same-target',
browserProfileId: retired.id
})
const gate = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
state.policy.mockImplementationOnce(() => {
started.resolve()
return gate.promise
})
const sibling = prepareLocalSshBrowserPartition({
targetId: 'same-target',
browserProfileId: survivor.id
})
await started.promise
try {
await expect(registry.deleteProfile(retired.id)).resolves.toBe(true)
expect(isLocalSshBrowserPartition(ready.partition)).toBe(false)
} finally {
gate.resolve()
await sibling
}
expect(isLocalSshBrowserPartition((await sibling).partition)).toBe(true)
await releaseLocalSshBrowserPartitionsForTarget('same-target')
})
it('clears the original jar even if route cleanup fails and preserves default refusal', async () => {
const retired = await profile('Cleanup failure')
const route = await prepareLocalSshBrowserPartition({
targetId: 'failed-target',
browserProfileId: retired.id
})
state.clearPolicy.mockImplementationOnce(() => {
throw new Error('policy cleanup failed')
})
await expect(registry.deleteProfile(retired.id)).resolves.toBe(true)
expect(isLocalSshBrowserPartition(route.partition)).toBe(false)
expect(session.fromPartition(route.partition).clearStorageData).toHaveBeenCalledTimes(1)
expect(session.fromPartition(retired.partition).clearStorageData).toHaveBeenCalledTimes(1)
await expect(registry.deleteProfile('default')).resolves.toBe(false)
expect(registry.getProfile('default')).not.toBeNull()
})
})