diff --git a/src/main/browser/browser-session-profile-retirement.ts b/src/main/browser/browser-session-profile-retirement.ts index a6ddabade8c..b10dc5f520a 100644 --- a/src/main/browser/browser-session-profile-retirement.ts +++ b/src/main/browser/browser-session-profile-retirement.ts @@ -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 | undefined ): Promise { + 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 { await detachBrowserExtensionsFromProfileSession(profile) // Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind. try { diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index f1918196660..87b273516dc 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -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) | 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 } diff --git a/src/main/browser/local-ssh-browser-partitions.ts b/src/main/browser/local-ssh-browser-partitions.ts index 18141fdb7f4..78226ca6364 100644 --- a/src/main/browser/local-ssh-browser-partitions.ts +++ b/src/main/browser/local-ssh-browser-partitions.ts @@ -38,6 +38,8 @@ type PreparedLocalSshPartition = { type PendingLocalSshPartition = { targetId: string + browserProfileId: string + storage: { partition: string | null } promise: Promise } @@ -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 { +async function prepareFresh( + input: { targetId: string; browserProfileId: string; skipProbe?: boolean }, + storage: PendingLocalSshPartition['storage'] +): Promise { 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 { await closeLocalSshBrowserRouteForTarget(targetId) + await releasePreparedPartitions((pending) => pending.targetId === targetId) +} + +async function releasePreparedPartitions( + owns: (pending: PendingLocalSshPartition) => boolean, + released = new Set() +): Promise { + let failure: unknown let contentsBySession: Map | 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() + try { + await releasePreparedPartitions((pending) => pending.browserProfileId === profileId, partitions) + } finally { + await releaseEvictedBrowserRoutePartitionStorage( + [...partitions], + isBrowserRoutePartitionRetainedByAnyOwner + ) + } +} diff --git a/src/main/browser/local-ssh-browser-profile-retirement.test.ts b/src/main/browser/local-ssh-browser-profile-retirement.test.ts new file mode 100644 index 00000000000..7031402e0f5 --- /dev/null +++ b/src/main/browser/local-ssh-browser-profile-retirement.test.ts @@ -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 + clearCache: ReturnType + setProxy: ReturnType + closeAllConnections: ReturnType + resolveProxy: ReturnType +} +type TestGuest = { session: TestSession; isDestroyed(): boolean; close(): void } +const state = vi.hoisted(() => { + const directories: string[] = [] + const guests: TestGuest[] = [] + return { + directory: '', + directories, + sessions: new Map(), + 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() + const started = Promise.withResolvers() + 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() + const started = Promise.withResolvers() + 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() + }) +})