fix(browser): harden Comet cookie import replay

This commit is contained in:
Timothy J Laurent
2026-05-15 12:54:20 -07:00
committed by GitHub
parent 9c601be199
commit 09e6911ea8
9 changed files with 668 additions and 63 deletions
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as childProcessModule from 'node:child_process'
import type * as fsModule from 'node:fs'
const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({
sessionFromPartitionMock: vi.fn(),
dialogShowOpenDialogMock: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: { fromWebContents: vi.fn() },
dialog: { showOpenDialog: dialogShowOpenDialogMock },
session: { fromPartition: sessionFromPartitionMock }
}))
import { BROWSER_FAMILY_LABELS } from '../../shared/constants'
describe('detectInstalledBrowsers — Comet', () => {
const originalPlatform = process.platform
const originalHome = process.env.HOME
beforeEach(() => {
// Why: browser-cookie-import.ts uses destructured named imports from
// 'node:fs' which are bound at module-load time. Without vi.resetModules(),
// only the first test's vi.doMock takes effect — subsequent tests see the
// cached module with the first mock still applied. resetModules must run
// BEFORE each doMock so the next import() picks up the fresh mock factory.
vi.resetModules()
Object.defineProperty(process, 'platform', { value: 'darwin' })
process.env.HOME = '/Users/test'
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
process.env.HOME = originalHome
vi.restoreAllMocks()
})
it('detects Comet when its data directory and Cookies DB exist', async () => {
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof fsModule>('node:fs')
return {
...actual,
existsSync: (p: string) => {
if (p.includes('Comet/Default/Network/Cookies')) {
return true
}
if (p.includes('Comet/Local State')) {
return true
}
return false
},
readFileSync: (p: string, enc?: string) => {
if (typeof p === 'string' && p.includes('Comet/Local State')) {
return JSON.stringify({ profile: { info_cache: { Default: { name: 'Default' } } } })
}
return actual.readFileSync(p as never, enc as never)
}
}
})
const { detectInstalledBrowsers } = await import('./browser-cookie-import')
const detected = detectInstalledBrowsers()
const comet = detected.find((b) => b.family === 'comet')
expect(comet).toBeDefined()
expect(comet?.label).toBe('Comet')
expect(comet?.cookiesPath).toContain('Comet/Default/Network/Cookies')
expect(comet?.keychainService).toBe('Comet Safe Storage')
})
it('does not list Comet when its data directory is absent', async () => {
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof fsModule>('node:fs')
return {
...actual,
existsSync: () => false
}
})
const { detectInstalledBrowsers } = await import('./browser-cookie-import')
const detected = detectInstalledBrowsers()
expect(detected.find((b) => b.family === 'comet')).toBeUndefined()
})
it('enumerates all Comet profiles from Local State info_cache', async () => {
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof fsModule>('node:fs')
return {
...actual,
existsSync: (p: string) => {
if (p.includes('Comet/Default/Network/Cookies')) {
return true
}
if (p.includes('Comet/Local State')) {
return true
}
return false
},
readFileSync: (p: string, enc?: string) => {
if (typeof p === 'string' && p.includes('Comet/Local State')) {
return JSON.stringify({
profile: {
info_cache: {
Default: { name: 'Personal' },
'Profile 1': { name: 'Work' },
'Profile 2': { name: 'Research' }
}
}
})
}
return actual.readFileSync(p as never, enc as never)
}
}
})
const { detectInstalledBrowsers } = await import('./browser-cookie-import')
const detected = detectInstalledBrowsers()
const comet = detected.find((b) => b.family === 'comet')
expect(comet).toBeDefined()
const directories = comet!.profiles.map((p) => p.directory).sort()
expect(directories).toEqual(['Default', 'Profile 1', 'Profile 2'])
const names = comet!.profiles.map((p) => p.name).sort()
expect(names).toEqual(['Personal', 'Research', 'Work'])
})
it('skips Comet when the data directory exists but no Cookies DB is present', async () => {
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof fsModule>('node:fs')
return {
...actual,
existsSync: (p: string) => {
if (p.includes('Comet/Local State')) {
return true
}
if (p.includes('Network/Cookies') || p.endsWith('/Cookies')) {
return false
}
return false
},
readFileSync: (p: string, enc?: string) => {
if (typeof p === 'string' && p.includes('Comet/Local State')) {
return JSON.stringify({ profile: { info_cache: { Default: { name: 'Default' } } } })
}
return actual.readFileSync(p as never, enc as never)
}
}
})
const { detectInstalledBrowsers } = await import('./browser-cookie-import')
const detected = detectInstalledBrowsers()
expect(detected.find((b) => b.family === 'comet')).toBeUndefined()
})
})
describe('getUserAgentForBrowser — Comet', () => {
const originalPlatform = process.platform
beforeEach(() => {
vi.resetModules()
Object.defineProperty(process, 'platform', { value: 'darwin' })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
vi.restoreAllMocks()
})
it('returns a Chrome-shaped UA string when Comet plist version reads successfully', async () => {
vi.doMock('node:child_process', async () => {
const actual = await vi.importActual<typeof childProcessModule>('node:child_process')
return {
...actual,
execFileSync: (cmd: string, args: readonly string[]) => {
if (cmd === 'defaults' && args[1]?.includes('/Applications/Comet.app/Contents/Info')) {
return '120.0.6099.71\n'
}
return actual.execFileSync(cmd, args as never)
}
}
})
const { getUserAgentForBrowser } = await import('./browser-cookie-import')
const ua = getUserAgentForBrowser('comet')
expect(ua).not.toBeNull()
expect(ua).toContain('Macintosh; Intel Mac OS X 10_15_7')
expect(ua).toContain('AppleWebKit/537.36')
expect(ua).toContain('Chrome/120.0.6099.71')
expect(ua).toContain('Safari/537.36')
})
it('returns null when reading the Comet plist version throws', async () => {
vi.doMock('node:child_process', async () => {
const actual = await vi.importActual<typeof childProcessModule>('node:child_process')
return {
...actual,
execFileSync: () => {
throw new Error('defaults: domain not found')
}
}
})
const { getUserAgentForBrowser } = await import('./browser-cookie-import')
const ua = getUserAgentForBrowser('comet')
expect(ua).toBeNull()
})
it('returns null on non-darwin platforms regardless of family', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' })
const { getUserAgentForBrowser } = await import('./browser-cookie-import')
const ua = getUserAgentForBrowser('comet')
expect(ua).toBeNull()
})
})
describe('BROWSER_FAMILY_LABELS — Comet', () => {
it('maps the comet family key to the user-facing label "Comet"', () => {
expect(BROWSER_FAMILY_LABELS.comet).toBe('Comet')
})
})
@@ -227,7 +227,7 @@ describe('detectInstalledBrowsers', () => {
it('each detected browser has a valid family', () => {
const browsers = detectInstalledBrowsers()
const validFamilies = ['chrome', 'edge', 'arc', 'chromium', 'firefox', 'safari']
const validFamilies = ['chrome', 'edge', 'arc', 'chromium', 'firefox', 'safari', 'comet']
for (const browser of browsers) {
expect(validFamilies).toContain(browser.family)
}
+27 -5
View File
@@ -2,12 +2,13 @@
that must stay together so the encryption, schema, and staging steps remain in sync. */
import { app, type BrowserWindow, dialog, session } from 'electron'
import { execFileSync } from 'node:child_process'
import { createDecipheriv, pbkdf2Sync } from 'node:crypto'
import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto'
import {
appendFileSync,
copyFileSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
@@ -113,6 +114,15 @@ const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [
macRoot: 'BraveSoftware/Brave-Browser',
winRoot: 'BraveSoftware/Brave-Browser/User Data',
linuxRoot: 'BraveSoftware/Brave-Browser'
},
{
family: 'comet',
label: 'Comet',
keychainService: 'Comet Safe Storage',
keychainAccount: 'Comet',
macRoot: 'Comet',
winRoot: 'Comet/User Data'
// linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15
}
]
@@ -658,7 +668,7 @@ export async function importCookiesFromFile(
// Why: Google and other services bind auth cookies to the User-Agent that
// created them. We read the source browser's real version from its plist
// and construct a matching UA string so imported sessions aren't invalidated.
function getUserAgentForBrowser(
export function getUserAgentForBrowser(
family: BrowserSessionProfileSource['browserFamily']
): string | null {
// Why: UA spoofing uses macOS-specific plist reading. On other platforms,
@@ -703,6 +713,12 @@ function getUserAgentForBrowser(
const v = readBrowserVersion('/Applications/Brave Browser.app')
return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null
}
case 'comet': {
// Why: Comet is Chromium-based and ships a Chrome-shaped version in its plist.
// Use the same UA shape as Chrome itself so Google-bound auth cookies survive import.
const v = readBrowserVersion('/Applications/Comet.app')
return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null
}
default:
return null
}
@@ -1343,8 +1359,14 @@ export async function importCookiesFromBrowser(
return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' }
}
const stagingCookiesPath = join(app.getPath('userData'), 'Cookies-staged')
const stagingDir = join(app.getPath('userData'), 'cookie-import-staging')
const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_')
const stagingCookiesPath = join(
stagingDir,
`Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}`
)
try {
mkdirSync(stagingDir, { recursive: true })
copyFileSync(liveCookiesPath, stagingCookiesPath)
} catch {
rmSync(tmpDir, { recursive: true, force: true })
@@ -1565,7 +1587,7 @@ export async function importCookiesFromBrowser(
// Why: some cookies couldn't be loaded via cookies.set() (non-ASCII values
// or other validation failures). Keep the staging DB so the next cold start
// picks them up from SQLite where CookieMonster reads them without validation.
browserSessionRegistry.setPendingCookieImport(stagingCookiesPath)
browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath)
diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`)
} else {
try {
@@ -1580,7 +1602,7 @@ export async function importCookiesFromBrowser(
if (ua) {
targetSession.setUserAgent(ua)
setupClientHintsOverride(targetSession, ua)
browserSessionRegistry.persistUserAgent(ua)
browserSessionRegistry.persistUserAgent(targetPartition, ua)
diag(` set UA for partition: ${ua.substring(0, 80)}...`)
}
@@ -0,0 +1,245 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const USER_DATA = '/user-data'
const META_PATH = `${USER_DATA}/browser-session-meta.json`
type FsState = {
files: Map<string, string>
present: Set<string>
}
function createFsState(): FsState {
return { files: new Map(), present: new Set() }
}
function seedMeta(fsState: FsState, meta: unknown): void {
const raw = JSON.stringify(meta)
fsState.files.set(META_PATH, raw)
fsState.present.add(META_PATH)
}
function installModuleMocks(
fsState: FsState,
copyFailures: Set<string> = new Set()
): {
sessionFromPartitionMock: ReturnType<typeof vi.fn>
setupClientHintsOverrideMock: ReturnType<typeof vi.fn>
} {
const sessionFromPartitionMock = vi.fn((partition: string) => ({
partition,
setUserAgent: vi.fn(),
getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/31 Orca'),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setDisplayMediaRequestHandler: vi.fn(),
on: vi.fn(),
clearStorageData: vi.fn().mockResolvedValue(undefined),
clearCache: vi.fn().mockResolvedValue(undefined)
}))
const setupClientHintsOverrideMock = vi.fn()
vi.doMock('electron', () => ({
app: { getPath: vi.fn(() => USER_DATA) },
session: { fromPartition: sessionFromPartitionMock },
systemPreferences: {
askForMediaAccess: vi.fn().mockResolvedValue(true),
getMediaAccessStatus: vi.fn(() => 'granted')
}
}))
vi.doMock('node:fs', () => ({
copyFileSync: vi.fn((src: string, dst: string) => {
if (copyFailures.has(src)) {
throw new Error(`copy fail for ${src}`)
}
fsState.present.add(dst)
const value = fsState.files.get(src)
if (value !== undefined) {
fsState.files.set(dst, value)
}
}),
existsSync: vi.fn((p: string) => fsState.present.has(p)),
mkdirSync: vi.fn(),
readFileSync: vi.fn((p: string) => {
const v = fsState.files.get(p)
if (v === undefined) {
throw new Error('ENOENT')
}
return v
}),
renameSync: vi.fn((from: string, to: string) => {
const v = fsState.files.get(from)
if (v === undefined) {
throw new Error('ENOENT')
}
fsState.files.set(to, v)
fsState.present.add(to)
fsState.files.delete(from)
fsState.present.delete(from)
}),
unlinkSync: vi.fn((p: string) => {
fsState.present.delete(p)
fsState.files.delete(p)
}),
writeFileSync: vi.fn((p: string, data: string | Uint8Array) => {
const value = typeof data === 'string' ? data : Buffer.from(data).toString('utf-8')
fsState.files.set(p, value)
fsState.present.add(p)
})
}))
vi.doMock('./browser-manager', () => ({
browserManager: {
notifyPermissionDenied: vi.fn(),
handleGuestWillDownload: vi.fn()
}
}))
vi.doMock('./browser-media-access', () => ({
hasSystemMediaAccess: vi.fn(() => true),
requestSystemMediaAccess: vi.fn().mockResolvedValue(true)
}))
vi.doMock('./browser-session-ua', () => ({
cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')),
setupClientHintsOverride: setupClientHintsOverrideMock
}))
return { sessionFromPartitionMock, setupClientHintsOverrideMock }
}
describe('BrowserSessionRegistry persistence', () => {
beforeEach(() => {
vi.resetModules()
vi.restoreAllMocks()
})
it('migrates and consumes legacy pendingCookieDbPath into default partition replay', async () => {
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: null,
pendingCookieDbPath: '/staged/legacy',
profiles: []
})
fsState.present.add('/staged/legacy')
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.applyPendingCookieImport()
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
expect(written.pendingCookieDbPath).toBeNull()
expect(written.pendingCookieImports).toEqual({})
expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(true)
})
it('merges partition-keyed pending entries without clobbering unrelated entries', async () => {
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: null,
userAgentByPartition: {},
pendingCookieDbPath: null,
pendingCookieImports: {},
profiles: []
})
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.setPendingCookieImport('persist:orca-browser', '/staged/default')
browserSessionRegistry.setPendingCookieImport(
'persist:orca-browser-session-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
'/staged/imported'
)
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
expect(written.pendingCookieDbPath).toBe('/staged/default')
expect(written.pendingCookieImports).toEqual({
'persist:orca-browser': '/staged/default',
'persist:orca-browser-session-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa': '/staged/imported'
})
})
it('restores persisted UA for non-default partitions', async () => {
const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111'
const importedUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36'
const defaultUa = 'Mozilla/5.0 Chrome/119.0.0.0 Safari/537.36'
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: defaultUa,
userAgentByPartition: {
'persist:orca-browser': defaultUa,
[importedPartition]: importedUa
},
pendingCookieDbPath: null,
pendingCookieImports: {},
profiles: [
{
id: '11111111-1111-4111-8111-111111111111',
scope: 'imported',
partition: importedPartition,
label: 'Imported',
source: { browserFamily: 'comet', importedAt: 1 }
}
]
})
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.restorePersistedUserAgent()
const importedSessions = sessionFromPartitionMock.mock.results
.filter((_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === importedPartition)
.map((r) => r.value)
expect(importedSessions.length).toBeGreaterThan(0)
expect(
importedSessions.some((s) =>
s.setUserAgent.mock.calls.some((c: unknown[]) => c[0] === importedUa)
)
).toBe(true)
expect(
setupClientHintsOverrideMock.mock.calls.some(
(c: unknown[]) =>
(c[0] as { partition?: string } | undefined)?.partition === importedPartition &&
c[1] === importedUa
)
).toBe(true)
})
it('keeps failed partition replay pending and removes unrelated missing entries', async () => {
const importedPartition = 'persist:orca-browser-session-22222222-2222-4222-8222-222222222222'
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: null,
userAgentByPartition: {},
pendingCookieDbPath: null,
pendingCookieImports: {
[importedPartition]: '/staged/imported',
'persist:orca-browser': '/staged/missing'
},
profiles: [
{
id: '22222222-2222-4222-8222-222222222222',
scope: 'imported',
partition: importedPartition,
label: 'Imported',
source: { browserFamily: 'comet', importedAt: 1 }
}
]
})
fsState.present.add('/staged/imported')
installModuleMocks(fsState, new Set(['/staged/imported']))
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.applyPendingCookieImport()
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
expect(written.pendingCookieImports).toEqual({ [importedPartition]: '/staged/imported' })
expect(written.pendingCookieDbPath).toBeNull()
})
})
@@ -122,6 +122,17 @@ describe('BrowserSessionRegistry', () => {
expect(updated!.source?.browserFamily).toBe('edge')
})
it('updates profile source with comet family', () => {
const profile = browserSessionRegistry.createProfile('imported', 'Comet Source Test')
expect(profile).not.toBeNull()
const updated = browserSessionRegistry.updateProfileSource(profile!.id, {
browserFamily: 'comet',
importedAt: Date.now()
})
expect(updated).not.toBeNull()
expect(updated!.source?.browserFamily).toBe('comet')
})
it('deletes a non-default profile', async () => {
const profile = browserSessionRegistry.createProfile('isolated', 'Delete Test')
expect(profile).not.toBeNull()
+162 -55
View File
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
@@ -22,7 +23,9 @@ import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-sess
type BrowserSessionMeta = {
defaultSource: BrowserSessionProfile['source']
userAgent: string | null
userAgentByPartition: Record<string, string>
pendingCookieDbPath: string | null
pendingCookieImports: Record<string, string>
profiles: BrowserSessionProfile[]
}
@@ -57,6 +60,11 @@ class BrowserSessionRegistry {
return this.loadPersistedMeta().defaultSource
}
private static partitionCookiesPath(partition: string): string {
const partitionName = partition.replace('persist:', '')
return join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
}
// Why: write-to-temp-then-rename is atomic on all supported platforms.
// A crash mid-write would only lose the temp file, not corrupt the live one.
private persistMeta(updates: Partial<BrowserSessionMeta>): void {
@@ -88,14 +96,41 @@ class BrowserSessionRegistry {
try {
const raw = readFileSync(this.metadataPath, 'utf-8')
const data = JSON.parse(raw)
const legacyUserAgent = typeof data?.userAgent === 'string' ? data.userAgent : null
const userAgentByPartition: Record<string, string> =
data && typeof data.userAgentByPartition === 'object' && data.userAgentByPartition
? { ...data.userAgentByPartition }
: {}
if (legacyUserAgent && !userAgentByPartition[ORCA_BROWSER_PARTITION]) {
userAgentByPartition[ORCA_BROWSER_PARTITION] = legacyUserAgent
}
const legacyPendingCookieDbPath =
typeof data?.pendingCookieDbPath === 'string' ? data.pendingCookieDbPath : null
const pendingCookieImports: Record<string, string> =
data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports
? { ...data.pendingCookieImports }
: {}
if (legacyPendingCookieDbPath && !pendingCookieImports[ORCA_BROWSER_PARTITION]) {
pendingCookieImports[ORCA_BROWSER_PARTITION] = legacyPendingCookieDbPath
}
return {
defaultSource: data?.defaultSource ?? null,
userAgent: data?.userAgent ?? null,
pendingCookieDbPath: data?.pendingCookieDbPath ?? null,
userAgent: legacyUserAgent,
userAgentByPartition,
pendingCookieDbPath: legacyPendingCookieDbPath,
pendingCookieImports,
profiles: Array.isArray(data?.profiles) ? data.profiles : []
}
} catch {
return { defaultSource: null, userAgent: null, pendingCookieDbPath: null, profiles: [] }
return {
defaultSource: null,
userAgent: null,
userAgentByPartition: {},
pendingCookieDbPath: null,
pendingCookieImports: {},
profiles: []
}
}
}
@@ -110,22 +145,6 @@ class BrowserSessionRegistry {
// after app is ready) ensures the default profile's source is populated.
restorePersistedUserAgent(): void {
const meta = this.loadPersistedMeta()
if (meta.userAgent) {
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
sess.setUserAgent(meta.userAgent)
setupClientHintsOverride(sess, meta.userAgent)
} else {
// Why: even without an imported session, the default Electron UA contains
// "Electron/X.X.X" and the app name which trip Cloudflare Turnstile.
try {
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
} catch {
/* session not available yet (e.g. unit tests or pre-ready) */
}
}
if (meta.defaultSource) {
const current = this.profiles.get('default')
if (current && current.source === null) {
@@ -135,6 +154,30 @@ class BrowserSessionRegistry {
if (meta.profiles.length > 0) {
this.hydrateFromPersisted(meta.profiles)
}
const partitions = new Set([
ORCA_BROWSER_PARTITION,
...this.listProfiles().map((p) => p.partition)
])
for (const partition of partitions) {
try {
const sess = session.fromPartition(partition)
const persistedUa = meta.userAgentByPartition[partition]
if (persistedUa) {
sess.setUserAgent(persistedUa)
setupClientHintsOverride(sess, persistedUa)
continue
}
// Why: even without an imported session, the default Electron UA contains
// "Electron/X.X.X" and the app name which trip Cloudflare Turnstile.
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
} catch {
/* session not available yet (e.g. unit tests or pre-ready) */
}
}
}
// Why: the import writes cookies to a staging DB because CookieMonster holds
@@ -144,56 +187,98 @@ class BrowserSessionRegistry {
applyPendingCookieImport(): void {
try {
const meta = this.loadPersistedMeta()
if (!meta.pendingCookieDbPath) {
return
}
if (!existsSync(meta.pendingCookieDbPath)) {
this.persistMeta({ pendingCookieDbPath: null })
const pendingEntries = Object.entries(meta.pendingCookieImports)
if (pendingEntries.length === 0) {
return
}
const knownPartitions = new Set([
ORCA_BROWSER_PARTITION,
...meta.profiles.map((p) => p.partition)
])
const remainingEntries = { ...meta.pendingCookieImports }
const partitionName = ORCA_BROWSER_PARTITION.replace('persist:', '')
const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
copyFileSync(meta.pendingCookieDbPath, liveCookiesPath)
// Why: SQLite WAL mode stores uncommitted data in sidecar files.
// Stale WAL/SHM from a previous session could corrupt CookieMonster's
// read of the freshly swapped DB.
for (const suffix of ['-wal', '-shm']) {
try {
unlinkSync(liveCookiesPath + suffix)
} catch {
/* may not exist */
for (const [partition, stagedPath] of pendingEntries) {
if (!knownPartitions.has(partition)) {
delete remainingEntries[partition]
continue
}
const stagingSidecar = meta.pendingCookieDbPath + suffix
if (existsSync(stagingSidecar)) {
try {
copyFileSync(stagingSidecar, liveCookiesPath + suffix)
} catch {
/* best-effort */
if (!existsSync(stagedPath)) {
delete remainingEntries[partition]
continue
}
const liveCookiesPath = BrowserSessionRegistry.partitionCookiesPath(partition)
try {
mkdirSync(join(liveCookiesPath, '..'), { recursive: true })
copyFileSync(stagedPath, liveCookiesPath)
// Why: SQLite WAL mode stores uncommitted data in sidecar files.
// Stale WAL/SHM from a previous session could corrupt CookieMonster's
// read of the freshly swapped DB.
let sidecarCopyFailed = false
for (const suffix of ['-wal', '-shm']) {
try {
unlinkSync(liveCookiesPath + suffix)
} catch {
/* may not exist */
}
const stagingSidecar = stagedPath + suffix
if (!existsSync(stagingSidecar)) {
continue
}
try {
copyFileSync(stagingSidecar, liveCookiesPath + suffix)
} catch {
sidecarCopyFailed = true
}
}
}
}
for (const ext of ['', '-wal', '-shm']) {
try {
unlinkSync(`${meta.pendingCookieDbPath}${ext}`)
if (sidecarCopyFailed) {
// Why: sidecar copy failures can leave an inconsistent replay state.
// Keep this entry for retry and preserve unrelated entries.
continue
}
for (const ext of ['', '-wal', '-shm']) {
try {
unlinkSync(`${stagedPath}${ext}`)
} catch {
/* best-effort */
}
}
delete remainingEntries[partition]
} catch {
/* best-effort */
// Why: failed replay for one partition should not drop unrelated entries.
// Keep this entry for retry next launch.
}
}
this.persistMeta({ pendingCookieDbPath: null })
this.persistMeta({
pendingCookieImports: remainingEntries,
pendingCookieDbPath: remainingEntries[ORCA_BROWSER_PARTITION] ?? null
})
} catch {
// best-effort — if this fails, CookieMonster loads the old DB
}
}
setPendingCookieImport(stagingDbPath: string): void {
this.persistMeta({ pendingCookieDbPath: stagingDbPath })
setPendingCookieImport(partition: string, stagingDbPath: string): void {
const meta = this.loadPersistedMeta()
const pendingCookieImports = { ...meta.pendingCookieImports, [partition]: stagingDbPath }
this.persistMeta({
pendingCookieImports,
pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null
})
}
persistUserAgent(userAgent: string | null): void {
const defaultProfile = this.profiles.get('default')
this.persistSource(defaultProfile?.source ?? null, userAgent)
persistUserAgent(partition: string, userAgent: string | null): void {
const meta = this.loadPersistedMeta()
const userAgentByPartition = { ...meta.userAgentByPartition }
if (userAgent) {
userAgentByPartition[partition] = userAgent
} else {
delete userAgentByPartition[partition]
}
this.persistMeta({
userAgentByPartition,
userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null
})
}
getDefaultProfile(): BrowserSessionProfile {
@@ -273,6 +358,17 @@ class BrowserSessionRegistry {
}
this.profiles.delete(profileId)
this.persistProfiles()
const meta = this.loadPersistedMeta()
const pendingCookieImports = { ...meta.pendingCookieImports }
delete pendingCookieImports[profile.partition]
const userAgentByPartition = { ...meta.userAgentByPartition }
delete userAgentByPartition[profile.partition]
this.persistMeta({
pendingCookieImports,
pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null,
userAgentByPartition,
userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null
})
// Why: clearing the partition's storage prevents orphaned cookies/cache from
// lingering after the user deletes an imported or isolated session profile.
@@ -298,7 +394,18 @@ class BrowserSessionRegistry {
if (defaultProfile) {
this.profiles.set('default', { ...defaultProfile, source: null })
}
this.persistMeta({ defaultSource: null, userAgent: null, pendingCookieDbPath: null })
const meta = this.loadPersistedMeta()
const pendingCookieImports = { ...meta.pendingCookieImports }
delete pendingCookieImports[ORCA_BROWSER_PARTITION]
const userAgentByPartition = { ...meta.userAgentByPartition }
delete userAgentByPartition[ORCA_BROWSER_PARTITION]
this.persistMeta({
defaultSource: null,
userAgent: null,
userAgentByPartition,
pendingCookieDbPath: null,
pendingCookieImports
})
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
await sess.clearStorageData({ storages: ['cookies'] })
@@ -228,7 +228,7 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
<SearchableSetting
id="browser-session-cookies"
title="Session & Cookies"
description="Manage browser profiles and import cookies from Chrome, Edge, or other browsers."
description="Manage browser profiles and import cookies from Chrome, Edge, Comet, or other browsers."
keywords={[
'cookies',
'session',
+1
View File
@@ -36,6 +36,7 @@ export const SSH_TERMINATE_RECONNECT_REQUIRED = 'SSH_TERMINATE_RECONNECT_REQUIRE
export const BROWSER_FAMILY_LABELS: Record<string, string> = {
chrome: 'Google Chrome',
chromium: 'Chromium',
comet: 'Comet',
arc: 'Arc',
edge: 'Microsoft Edge',
brave: 'Brave',
+1 -1
View File
@@ -384,7 +384,7 @@ export type BrowserTab = BrowserWorkspace
export type BrowserSessionProfileScope = 'default' | 'isolated' | 'imported'
export type BrowserSessionProfileSource = {
browserFamily: 'chrome' | 'chromium' | 'arc' | 'edge' | 'firefox' | 'safari' | 'manual'
browserFamily: 'chrome' | 'chromium' | 'arc' | 'edge' | 'firefox' | 'safari' | 'comet' | 'manual'
profileName?: string
importedAt: number
}