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