mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 16:02:37 +00:00
fix(browser): bulk clear cookies during native import (#13966)
* fix(browser): bulk clear cookies during native import * fix(browser): separate Google cookie import warning * fix(browser): report encrypted Google cookie exclusions * fix(browser): skip key lookup for excluded cookies * test(browser): cover excluded-cookie key bypass * perf(browser): keep plaintext key scan cheap
This commit is contained in:
@@ -89,6 +89,7 @@ describe('file import excludes the Google cookie family', () => {
|
||||
totalCookies: 4,
|
||||
importedCookies: 2,
|
||||
skippedCookies: 2,
|
||||
googleCookiesSkipped: 2,
|
||||
domains: ['linear.app', 'youtube.com']
|
||||
})
|
||||
expect(cookiesRemoveMock.mock.calls).toEqual([['https://linear.app/', 'old-linear']])
|
||||
@@ -110,6 +111,7 @@ describe('file import excludes the Google cookie family', () => {
|
||||
totalCookies: 1,
|
||||
importedCookies: 0,
|
||||
skippedCookies: 1,
|
||||
googleCookiesSkipped: 1,
|
||||
domains: []
|
||||
})
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
@@ -164,7 +166,7 @@ describe('native Chromium import excludes the Google cookie family', () => {
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, rows).close()
|
||||
}
|
||||
|
||||
it('never wipes the jar wholesale and keeps the live Google cookies', async () => {
|
||||
it('bulk clears once and restores the live Google cookies before importing', async () => {
|
||||
const sourceCookiesPath = seedSource([
|
||||
{ domain: '.google.com', name: 'SID', value: 'transplanted-sid' },
|
||||
{ domain: '.example.com', name: 'session', value: 'new' }
|
||||
@@ -181,11 +183,67 @@ describe('native Chromium import excludes the Google cookie family', () => {
|
||||
totalCookies: 2,
|
||||
importedCookies: 1,
|
||||
skippedCookies: 1,
|
||||
googleCookiesSkipped: 1,
|
||||
domains: ['example.com']
|
||||
})
|
||||
expect(clearStorageDataMock).toHaveBeenCalledOnce()
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] })
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual([
|
||||
'.google.com',
|
||||
'.example.com'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an excluded Google row even when its encrypted value is invalid', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{
|
||||
domain: '.google.com',
|
||||
name: 'SID',
|
||||
value: '',
|
||||
encryptedValue: Buffer.from('v10-invalid')
|
||||
},
|
||||
{ domain: '.example.com', name: 'session', value: 'new' }
|
||||
]).close()
|
||||
seedTarget([])
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok && result.summary).toMatchObject({
|
||||
totalCookies: 2,
|
||||
importedCookies: 1,
|
||||
skippedCookies: 1,
|
||||
googleCookiesSkipped: 1
|
||||
})
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['session'])
|
||||
})
|
||||
|
||||
it('does not request an encryption key for excluded Google rows', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{
|
||||
domain: '.google.com',
|
||||
name: 'SID',
|
||||
value: '',
|
||||
encryptedValue: Buffer.from('v10-invalid')
|
||||
}
|
||||
]).close()
|
||||
seedTarget([])
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok && result.summary).toEqual({
|
||||
totalCookies: 1,
|
||||
importedCookies: 0,
|
||||
skippedCookies: 1,
|
||||
googleCookiesSkipped: 1,
|
||||
domains: []
|
||||
})
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
expect(clearStorageDataMock).not.toHaveBeenCalled()
|
||||
expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'stale']])
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual(['.example.com'])
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the live Google rows in the staged restart-fallback database', async () => {
|
||||
@@ -211,6 +269,27 @@ describe('native Chromium import excludes the Google cookie family', () => {
|
||||
{ host_key: '.google.com', name: 'SID', value: 'live-sid' }
|
||||
])
|
||||
})
|
||||
|
||||
it('fails the import and restores the full snapshot when the bulk clear rejects', async () => {
|
||||
const sourceCookiesPath = seedSource([
|
||||
{ domain: '.example.com', name: 'session', value: 'new' }
|
||||
])
|
||||
seedTarget([{ domain: '.example.com', name: 'stale', value: 'stale' }])
|
||||
cookiesGetMock.mockResolvedValue([
|
||||
existingCookie('.google.com', 'SID'),
|
||||
existingCookie('.example.com', 'stale')
|
||||
])
|
||||
clearStorageDataMock.mockRejectedValue(new Error('cookie store unavailable'))
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result).toMatchObject({ ok: false })
|
||||
expect(result.ok || result.reason).toContain('Could not clear existing cookies')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledOnce()
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'stale'])
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Why: freshly imported rows store the decrypted value as a BLOB; read both forms as text.
|
||||
|
||||
@@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { Cookie } from 'electron'
|
||||
import {
|
||||
bulkClearCookiesExcept,
|
||||
isGoogleSourceBoundCookie,
|
||||
isNonTransplantableCookieDomain,
|
||||
NON_TRANSPLANTABLE_HOST_KEY_SQL,
|
||||
normalizeCookieDomain,
|
||||
removeAllCookiesExcept,
|
||||
replaceCookiesForImportedDomains
|
||||
} from './browser-cookie-import-policy'
|
||||
|
||||
@@ -207,104 +207,87 @@ describe('NON_TRANSPLANTABLE_HOST_KEY_SQL', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeAllCookiesExcept', () => {
|
||||
it('removes only the cookies the predicate does not exclude', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
cookie('.google.com', 'SID'),
|
||||
cookie('.example.com', 'session'),
|
||||
cookie('other.test', 'tracker', '/scoped')
|
||||
])
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
describe('bulkClearCookiesExcept', () => {
|
||||
it('bulk clears a large jar once and restores only excluded cookies', async () => {
|
||||
const existing = [
|
||||
cookie('.google.com', 'SID'),
|
||||
cookie('accounts.google.com', 'ACCOUNT'),
|
||||
...Array.from({ length: 1_000 }, (_, index) =>
|
||||
cookie(`site-${index}.example`, `session-${index}`)
|
||||
)
|
||||
]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const clearStorageData = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await removeAllCookiesExcept({ get, remove, set }, (c) => c.domain === '.google.com')
|
||||
await bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, (existingCookie) =>
|
||||
isNonTransplantableCookieDomain(existingCookie.domain ?? '')
|
||||
)
|
||||
|
||||
expect(remove.mock.calls).toEqual([
|
||||
['https://example.com/', 'session'],
|
||||
['https://other.test/scoped', 'tracker']
|
||||
])
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
expect(get).toHaveBeenCalledOnce()
|
||||
expect(get).toHaveBeenCalledWith({})
|
||||
expect(clearStorageData).toHaveBeenCalledOnce()
|
||||
expect(clearStorageData).toHaveBeenCalledWith({ storages: ['cookies'] })
|
||||
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'ACCOUNT'])
|
||||
})
|
||||
|
||||
it('restores every successfully removed cookie when another removal fails', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
cookie('.example.com', 'first', '/one'),
|
||||
cookie('.example.com', 'second', '/two'),
|
||||
cookie('.example.com', 'third', '/three')
|
||||
])
|
||||
const remove = vi.fn().mockImplementation(async (_url: string, name: string) => {
|
||||
if (name === 'second') {
|
||||
throw new Error('store unavailable')
|
||||
it('restores the complete snapshot when the bulk clear rejects', async () => {
|
||||
const existing = [
|
||||
cookie('.google.com', 'SID'),
|
||||
cookie('.example.com', 'first'),
|
||||
cookie('.other.test', 'second')
|
||||
]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const clearStorageData = vi.fn().mockRejectedValue(new Error('store unavailable'))
|
||||
|
||||
await expect(
|
||||
bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, () => true)
|
||||
).rejects.toThrow('Could not clear existing cookies')
|
||||
|
||||
expect(clearStorageData).toHaveBeenCalledOnce()
|
||||
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'first', 'second'])
|
||||
})
|
||||
|
||||
it('rolls back the complete snapshot when excluded-cookie restoration fails', async () => {
|
||||
const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
let googleAttempts = 0
|
||||
const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => {
|
||||
if (name === 'SID' && googleAttempts++ === 0) {
|
||||
throw new Error('transient restore failure')
|
||||
}
|
||||
})
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const clearStorageData = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await expect(removeAllCookiesExcept({ get, remove, set }, () => false)).rejects.toThrow(
|
||||
'Could not clear existing cookies'
|
||||
)
|
||||
expect(remove).toHaveBeenCalledTimes(3)
|
||||
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['first', 'third'])
|
||||
})
|
||||
|
||||
it('bounds parallel removals so large cookie jars do not clear serially or fan out', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
Array.from({ length: 12 }, (_, index) => cookie('.example.com', `${index}`))
|
||||
await expect(
|
||||
bulkClearCookiesExcept(
|
||||
{ cookies: { get, set }, clearStorageData },
|
||||
(existingCookie) => existingCookie.domain === '.google.com'
|
||||
)
|
||||
let releaseRemovals: (() => void) | undefined
|
||||
const removalsReleased = new Promise<void>((resolve) => {
|
||||
releaseRemovals = resolve
|
||||
})
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const remove = vi.fn().mockImplementation(async () => {
|
||||
active++
|
||||
maxActive = Math.max(maxActive, active)
|
||||
await removalsReleased
|
||||
active--
|
||||
})
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
).rejects.toThrow('Could not preserve excluded cookies')
|
||||
|
||||
const clearing = removeAllCookiesExcept({ get, remove, set }, () => false)
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8))
|
||||
expect(maxActive).toBe(8)
|
||||
releaseRemovals?.()
|
||||
await clearing
|
||||
|
||||
expect(remove).toHaveBeenCalledTimes(12)
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
expect(clearStorageData).toHaveBeenCalledOnce()
|
||||
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session'])
|
||||
})
|
||||
|
||||
it('serializes cookies that share Electron removal coordinates', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
cookie('.example.com', 'session'),
|
||||
{ ...cookie('example.com', 'session'), hostOnly: true }
|
||||
])
|
||||
let releaseFirst: (() => void) | undefined
|
||||
const firstReleased = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
it('reports when the complete-snapshot rollback also fails', async () => {
|
||||
const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => {
|
||||
if (name === 'SID') {
|
||||
throw new Error('persistent restore failure')
|
||||
}
|
||||
})
|
||||
const remove = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => firstReleased)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const clearStorageData = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const clearing = removeAllCookiesExcept({ get, remove, set }, () => false)
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce())
|
||||
releaseFirst?.()
|
||||
await clearing
|
||||
await expect(
|
||||
bulkClearCookiesExcept(
|
||||
{ cookies: { get, set }, clearStorageData },
|
||||
(existingCookie) => existingCookie.domain === '.google.com'
|
||||
)
|
||||
).rejects.toThrow('Cookie preservation and rollback failed')
|
||||
|
||||
expect(remove.mock.calls).toEqual([
|
||||
['https://example.com/', 'session'],
|
||||
['https://example.com/', 'session']
|
||||
])
|
||||
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Cookie, Cookies } from 'electron'
|
||||
import type { Cookie, Cookies, Session } from 'electron'
|
||||
import { parse as parseDomain } from 'psl'
|
||||
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
|
||||
const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([
|
||||
'SIDCC',
|
||||
@@ -64,7 +63,6 @@ export function normalizeCookieImportDomain(domain: string): string | null {
|
||||
// its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports
|
||||
// users actually asked for.
|
||||
const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const
|
||||
const COOKIE_CLEAR_CONCURRENCY = 8
|
||||
|
||||
export function isNonTransplantableCookieDomain(domain: string): boolean {
|
||||
const normalized = normalizeCookieDomain(domain)
|
||||
@@ -196,53 +194,55 @@ export async function restoreImportedDomainCookies(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: clearStorageData wipes the whole jar, including the non-transplantable families an
|
||||
// import is never allowed to remove; this is the clear step that can leave them in place.
|
||||
export async function removeAllCookiesExcept(
|
||||
store: Pick<Cookies, 'get' | 'remove' | 'set'>,
|
||||
type CookieClearSession = {
|
||||
cookies: Pick<Cookies, 'get' | 'set'>
|
||||
clearStorageData: Session['clearStorageData']
|
||||
}
|
||||
|
||||
async function restoreCookieClearSnapshot(
|
||||
store: Pick<Cookies, 'set'>,
|
||||
snapshot: readonly Cookie[],
|
||||
originalError: unknown,
|
||||
rollbackMessage: string
|
||||
): Promise<never> {
|
||||
try {
|
||||
await restoreImportedDomainCookies(store, snapshot)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError([originalError, rollbackError], rollbackMessage)
|
||||
}
|
||||
throw originalError
|
||||
}
|
||||
|
||||
// Why: after a bulk clear starts, Electron cannot reveal whether a rejected operation mutated
|
||||
// the jar, so keep the complete snapshot until excluded cookies have been restored.
|
||||
export async function bulkClearCookiesExcept(
|
||||
targetSession: CookieClearSession,
|
||||
isExcluded: (cookie: Cookie) => boolean
|
||||
): Promise<void> {
|
||||
const existingCookies = await store.get({})
|
||||
const removableGroups = new Map<string, { cookie: Cookie; url: string }[]>()
|
||||
for (const cookie of existingCookies) {
|
||||
if (isExcluded(cookie)) {
|
||||
continue
|
||||
}
|
||||
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
|
||||
const url = domain ? cookieRemovalUrl(cookie, domain) : null
|
||||
if (!url) {
|
||||
continue
|
||||
}
|
||||
const key = JSON.stringify([url, cookie.name])
|
||||
const group = removableGroups.get(key) ?? []
|
||||
group.push({ cookie, url })
|
||||
removableGroups.set(key, group)
|
||||
const snapshot = await targetSession.cookies.get({})
|
||||
const excludedCookies = snapshot.filter(isExcluded)
|
||||
|
||||
try {
|
||||
await targetSession.clearStorageData({ storages: ['cookies'] })
|
||||
} catch (clearError) {
|
||||
await restoreCookieClearSnapshot(
|
||||
targetSession.cookies,
|
||||
snapshot,
|
||||
new AggregateError([clearError], 'Could not clear existing cookies'),
|
||||
'Cookie bulk clear and rollback failed'
|
||||
)
|
||||
}
|
||||
|
||||
const removedCookies: Cookie[] = []
|
||||
const results = await mapSettledWithConcurrency(
|
||||
[...removableGroups.values()],
|
||||
COOKIE_CLEAR_CONCURRENCY,
|
||||
async (group) => {
|
||||
// Why: identical remove keys must stay ordered so duplicate scoped cookies are not raced.
|
||||
for (const { cookie, url } of group) {
|
||||
await store.remove(url, cookie.name)
|
||||
removedCookies.push(cookie)
|
||||
}
|
||||
}
|
||||
)
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === 'rejected' ? [result.reason] : []
|
||||
)
|
||||
if (failures.length === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await restoreImportedDomainCookies(store, removedCookies)
|
||||
} catch (restoreError) {
|
||||
throw new AggregateError([...failures, restoreError], 'Cookie clearing and rollback failed')
|
||||
await restoreImportedDomainCookies(targetSession.cookies, excludedCookies)
|
||||
} catch (preservationError) {
|
||||
await restoreCookieClearSnapshot(
|
||||
targetSession.cookies,
|
||||
snapshot,
|
||||
new AggregateError([preservationError], 'Could not preserve excluded cookies'),
|
||||
'Cookie preservation and rollback failed'
|
||||
)
|
||||
}
|
||||
throw new AggregateError(failures, 'Could not clear existing cookies')
|
||||
}
|
||||
|
||||
export async function replaceCookiesForImportedDomains(
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('validated cookie replacement', () => {
|
||||
totalCookies: 3,
|
||||
importedCookies: 1,
|
||||
skippedCookies: 2,
|
||||
googleCookiesSkipped: 2,
|
||||
domains: ['example.com']
|
||||
})
|
||||
expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'old-example']])
|
||||
@@ -99,6 +100,7 @@ describe('validated cookie replacement', () => {
|
||||
totalCookies: 1,
|
||||
importedCookies: 0,
|
||||
skippedCookies: 1,
|
||||
googleCookiesSkipped: 1,
|
||||
domains: []
|
||||
})
|
||||
expect(cookiesGetMock).not.toHaveBeenCalled()
|
||||
@@ -215,6 +217,7 @@ describe('native Chromium integrity-cookie accounting', () => {
|
||||
totalCookies: 3,
|
||||
importedCookies: 1,
|
||||
skippedCookies: 2,
|
||||
googleCookiesSkipped: 2,
|
||||
domains: ['example.com']
|
||||
})
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['AEC'])
|
||||
@@ -246,6 +249,7 @@ describe('native Chromium integrity-cookie accounting', () => {
|
||||
totalCookies: 3,
|
||||
importedCookies: 0,
|
||||
skippedCookies: 3,
|
||||
googleCookiesSkipped: 2,
|
||||
domains: []
|
||||
})
|
||||
expect(clearStorageDataMock).not.toHaveBeenCalled()
|
||||
|
||||
@@ -511,9 +511,8 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
['', '-wal', '-shm'].map((suffix) => readFileSync(sourceCookiesPath + suffix))
|
||||
).toEqual(sourceFilesBefore)
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
// Why: STA-3811 — the pre-import clear is selective now, so a wholesale wipe would
|
||||
// take the non-transplantable families with it.
|
||||
expect(clearStorageDataMock).not.toHaveBeenCalled()
|
||||
expect(clearStorageDataMock).toHaveBeenCalledOnce()
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] })
|
||||
// Why: STA-3514 — imports must never impersonate the source browser; the
|
||||
// session keeps the engine UA the registry set at startup.
|
||||
expect(setUserAgentMock).not.toHaveBeenCalled()
|
||||
|
||||
@@ -75,12 +75,12 @@ import type {
|
||||
} from '../../shared/types'
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import {
|
||||
bulkClearCookiesExcept,
|
||||
isGoogleSourceBoundCookie,
|
||||
isNonTransplantableCookieDomain,
|
||||
NON_TRANSPLANTABLE_HOST_KEY_SQL,
|
||||
normalizeCookieDomain,
|
||||
normalizeCookieImportDomain,
|
||||
removeAllCookiesExcept,
|
||||
replaceCookiesForImportedDomains,
|
||||
restoreImportedDomainCookies,
|
||||
type CookieImportMode
|
||||
@@ -584,6 +584,7 @@ async function importValidatedCookies(
|
||||
)
|
||||
const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length
|
||||
const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length
|
||||
const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped
|
||||
const invalidDomainSkipped = cookies.length - validDomainCookies.length
|
||||
diag(
|
||||
`importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"`
|
||||
@@ -693,6 +694,7 @@ async function importValidatedCookies(
|
||||
totalCookies: totalInput,
|
||||
importedCookies: importedCount,
|
||||
skippedCookies: skipped,
|
||||
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
|
||||
domains: [...domainSet].sort()
|
||||
}
|
||||
|
||||
@@ -1590,7 +1592,12 @@ export async function importCookiesFromBrowser(
|
||||
|
||||
const needsSourceKey = sourceRows.some((sourceRow) => {
|
||||
const encRaw = sourceRow.encrypted_value
|
||||
return encRaw instanceof Uint8Array && encRaw.length > 0
|
||||
if (!(encRaw instanceof Uint8Array) || encRaw.length === 0) {
|
||||
return false
|
||||
}
|
||||
const domain = sourceRow.host_key as string
|
||||
const name = sourceRow.name as string
|
||||
return !(isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain))
|
||||
})
|
||||
const sourceKey = needsSourceKey
|
||||
? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser)
|
||||
@@ -1653,6 +1660,20 @@ export async function importCookiesFromBrowser(
|
||||
}
|
||||
|
||||
for (const sourceRow of sourceRows) {
|
||||
const domain = sourceRow.host_key as string
|
||||
const name = sourceRow.name as string
|
||||
|
||||
if (isGoogleSourceBoundCookie(name, domain)) {
|
||||
integritySkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Why: transplanting these replaces a working sign-in with a session the site rejects.
|
||||
if (isNonTransplantableCookieDomain(domain)) {
|
||||
nonTransplantableSkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const encRaw = sourceRow.encrypted_value
|
||||
// Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value.
|
||||
const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null
|
||||
@@ -1674,20 +1695,6 @@ export async function importCookiesFromBrowser(
|
||||
decryptedValue = Buffer.alloc(0)
|
||||
}
|
||||
|
||||
const domain = sourceRow.host_key as string
|
||||
const name = sourceRow.name as string
|
||||
|
||||
if (isGoogleSourceBoundCookie(name, domain)) {
|
||||
integritySkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Why: transplanting these replaces a working sign-in with a session the site rejects.
|
||||
if (isNonTransplantableCookieDomain(domain)) {
|
||||
nonTransplantableSkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
let validDomain = sourceDomainValidity.get(domain)
|
||||
if (validDomain === undefined) {
|
||||
validDomain = normalizeCookieImportDomain(domain) !== null
|
||||
@@ -1740,6 +1747,7 @@ export async function importCookiesFromBrowser(
|
||||
diag(
|
||||
` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies`
|
||||
)
|
||||
const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped
|
||||
|
||||
if (decryptedCookies.length === 0) {
|
||||
closeStagingDb()
|
||||
@@ -1751,6 +1759,7 @@ export async function importCookiesFromBrowser(
|
||||
totalCookies: sourceRows.length,
|
||||
importedCookies: 0,
|
||||
skippedCookies: skipped + integritySkipped + nonTransplantableSkipped,
|
||||
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
|
||||
domains: []
|
||||
}
|
||||
}
|
||||
@@ -1771,9 +1780,11 @@ export async function importCookiesFromBrowser(
|
||||
// Why: clear stale cookies first; mixing them with the imported set makes sites reject the
|
||||
// session. Non-transplantable families are exempt — nothing was imported for them, and their
|
||||
// live session is the only one that works.
|
||||
await removeAllCookiesExcept(targetSession.cookies, (cookie) =>
|
||||
await bulkClearCookiesExcept(targetSession, (cookie) =>
|
||||
isNonTransplantableCookieDomain(cookie.domain ?? '')
|
||||
)
|
||||
// Why: after excluded cookies survive this boundary, restart staging owns later set failures;
|
||||
// restoring stale non-Google cookies would recreate the mixed jar this import must replace.
|
||||
diag(
|
||||
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
|
||||
)
|
||||
@@ -1842,6 +1853,7 @@ export async function importCookiesFromBrowser(
|
||||
totalCookies: sourceRows.length,
|
||||
importedCookies: imported,
|
||||
skippedCookies: skipped + integritySkipped + nonTransplantableSkipped,
|
||||
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
|
||||
domains: [...domainSet].sort(),
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
|
||||
@@ -756,7 +756,8 @@
|
||||
"import": {
|
||||
"toast": {
|
||||
"restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.",
|
||||
"restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again."
|
||||
"restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.",
|
||||
"googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,4 +69,52 @@ describe('emitBrowserCookieImportToast', () => {
|
||||
expect(successToastMock).toHaveBeenCalledWith('Imported 3 cookies.')
|
||||
expect(warningToastMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a separate Google sign-in warning after the concise success toast', () => {
|
||||
emitBrowserCookieImportToast(
|
||||
{ ...summary, importedCookies: 2, skippedCookies: 1, googleCookiesSkipped: 1 },
|
||||
'Imported 2 cookies.'
|
||||
)
|
||||
|
||||
expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.')
|
||||
expect(warningToastMock).toHaveBeenCalledWith(
|
||||
'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.'
|
||||
)
|
||||
expect(successToastMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
warningToastMock.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not infer a Google warning from generic skipped cookies', () => {
|
||||
emitBrowserCookieImportToast(
|
||||
{ ...summary, importedCookies: 2, skippedCookies: 1 },
|
||||
'Imported 2 cookies.'
|
||||
)
|
||||
|
||||
expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.')
|
||||
expect(warningToastMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps both applicable warnings when restart fallback is unavailable', () => {
|
||||
emitBrowserCookieImportToast(
|
||||
{
|
||||
...summary,
|
||||
importedCookies: 1,
|
||||
skippedCookies: 2,
|
||||
googleCookiesSkipped: 1,
|
||||
warning: {
|
||||
code: 'restart-fallback-unavailable',
|
||||
loadedCookies: 1,
|
||||
failedCookies: 1
|
||||
}
|
||||
},
|
||||
'Imported 1 cookie.'
|
||||
)
|
||||
|
||||
expect(successToastMock).not.toHaveBeenCalled()
|
||||
expect(warningToastMock.mock.calls.map(([message]) => message)).toEqual([
|
||||
'Imported 1 of 2 cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.',
|
||||
'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,18 @@ function formatCookieImportWarning(warning: CookieImportWarning): string {
|
||||
}
|
||||
}
|
||||
|
||||
function emitGoogleCookieImportWarning(summary: BrowserCookieImportSummary): void {
|
||||
if (!summary.googleCookiesSkipped) {
|
||||
return
|
||||
}
|
||||
toast.warning(
|
||||
translate(
|
||||
'auto.lib.browser.cookie.import.toast.googleCookiesSkipped',
|
||||
'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a degraded import returns ok:true with a warning, so every call site must route it to a
|
||||
// warning toast instead of reporting an unqualified success (#9355).
|
||||
export function emitBrowserCookieImportToast(
|
||||
@@ -33,7 +45,8 @@ export function emitBrowserCookieImportToast(
|
||||
const warning = summary.warning
|
||||
if (warning) {
|
||||
toast.warning(formatCookieImportWarning(warning))
|
||||
return
|
||||
} else {
|
||||
toast.success(successMessage)
|
||||
}
|
||||
toast.success(successMessage)
|
||||
emitGoogleCookieImportWarning(summary)
|
||||
}
|
||||
|
||||
@@ -1079,6 +1079,7 @@ export type BrowserCookieImportSummary = {
|
||||
totalCookies: number
|
||||
importedCookies: number
|
||||
skippedCookies: number
|
||||
googleCookiesSkipped?: number
|
||||
domains: string[]
|
||||
warning?: {
|
||||
code: 'restart-fallback-unavailable'
|
||||
|
||||
Reference in New Issue
Block a user