fix(browser): route every cookie-import write through CDP identities (STA-4300) (#14729)

* test(browser): repro STA-4300 CHIPS partition downgrade on native cookie import success path

* fix(browser): route every cookie-import write through CDP identities (STA-4300)

* test(browser): cover partition fidelity on both import write paths (STA-4300)

* fix(browser): never stage unreadable cookie partitions (STA-4300)

* fix(browser): gate partition skips on client support (STA-4300)

* test(browser): anchor the client partition-skip capability assertion (STA-4300)

* fix(browser): skip Firefox CHIPS cookies whose Chromium identity cannot be rebuilt (STA-4300)

* fix(browser): tolerate a Firefox schema without originAttributes (STA-4300)

* docs(browser): correct a comment that still named the removed cookies.set write

* fix(browser): block lossy Firefox cookie import on old clients

* fix(browser): read Firefox CHIPS from schema flag
This commit is contained in:
Brennan Benson
2026-08-16 11:46:45 -07:00
committed by GitHub
parent 516a269315
commit bf6dc6fcba
25 changed files with 2032 additions and 261 deletions
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Cookie } from 'electron'
import {
cdpRestoreParamsFromIdentity,
cdpSetCookieParamsFromIdentity,
cookieClearIdentitiesFromCdp
} from './browser-cookie-clear-store'
@@ -37,7 +37,7 @@ describe('cookie clear CDP identities', () => {
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
})
])
expect(cdpRestoreParamsFromIdentity(identities[0])).toEqual(
expect(cdpSetCookieParamsFromIdentity(identities[0])).toEqual(
expect.objectContaining({
name: 'chips-auth',
sameSite: 'None',
@@ -72,8 +72,8 @@ describe('cookie clear CDP identities', () => {
expect.objectContaining({ value: 'host', domain: 'example.com', hostOnly: true }),
expect.objectContaining({ value: 'domain', domain: '.example.com', hostOnly: false })
])
expect(cdpRestoreParamsFromIdentity(identities[0])).not.toHaveProperty('domain')
expect(cdpRestoreParamsFromIdentity(identities[1])).toHaveProperty('domain', '.example.com')
expect(cdpSetCookieParamsFromIdentity(identities[0])).not.toHaveProperty('domain')
expect(cdpSetCookieParamsFromIdentity(identities[1])).toHaveProperty('domain', '.example.com')
})
it('indexes CDP cookies once instead of rescanning the jar for every cookie', () => {
@@ -99,7 +99,7 @@ describe('cookie clear CDP identities', () => {
it('does not turn an unspecified SameSite policy into explicit Lax', () => {
expect(
cdpRestoreParamsFromIdentity({
cdpSetCookieParamsFromIdentity({
url: 'https://example.com/',
name: 'unspecified',
value: 'value',
+57 -30
View File
@@ -4,7 +4,8 @@ import { normalizeCookieDomain } from './browser-cookie-import-policy'
import type {
CookieClearIdentity,
CookieClearPartitionKey,
CookieClearStore
CookieClearStore,
CookieImportWriteStore
} from './browser-cookie-import-clear'
type CdpCookiePartitionKey = {
@@ -120,25 +121,24 @@ function identityFromCdpCookie(url: string, cdpCookie: CdpCookie): CookieClearId
}
}
async function attachCookieClearSession(targetSession: Session): Promise<CookieClearSession> {
const existing = findPartitionWebContents(targetSession)
const window = existing
? null
: new BrowserWindow({
show: false,
webPreferences: {
session: targetSession,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
})
try {
if (window) {
await window.loadURL('data:text/html,<!doctype html><title>cookie-clear</title>')
function openHiddenCookieWindow(targetSession: Session): BrowserWindow {
return new BrowserWindow({
show: false,
webPreferences: {
session: targetSession,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
const contents = existing ?? window?.webContents
if (!contents || contents.isDestroyed()) {
})
}
async function leaseHiddenCookieDebugger(targetSession: Session): Promise<CookieClearSession> {
const window = openHiddenCookieWindow(targetSession)
try {
await window.loadURL('data:text/html,<!doctype html><title>cookie-clear</title>')
const contents = window.webContents
if (contents.isDestroyed()) {
throw new Error('Could not attach to the cookie session for an atomic clear')
}
const lease = acquireElectronDebugger(contents)
@@ -146,15 +146,30 @@ async function attachCookieClearSession(targetSession: Session): Promise<CookieC
debugger: contents.debugger,
dispose: () => {
lease.release()
window?.destroy()
window.destroy()
}
}
} catch (error) {
window?.destroy()
window.destroy()
throw error
}
}
async function attachCookieClearSession(targetSession: Session): Promise<CookieClearSession> {
const existing = findPartitionWebContents(targetSession)
if (!existing) {
return leaseHiddenCookieDebugger(targetSession)
}
try {
const lease = acquireElectronDebugger(existing)
return { debugger: existing.debugger, dispose: () => lease.release() }
} catch {
// Why (STA-4300): every cookie write now needs this channel, and attaching to a live tab fails
// outright when DevTools already owns its debugger. A hidden window of our own always can.
return leaseHiddenCookieDebugger(targetSession)
}
}
export function cookieClearIdentitiesFromCdp(
cookies: readonly { cookie: Cookie; url: string }[],
cdpCookies: readonly CdpCookie[]
@@ -191,7 +206,7 @@ export function cookieClearIdentitiesFromCdp(
return identities
}
export function cdpRestoreParamsFromIdentity(
export function cdpSetCookieParamsFromIdentity(
identity: CookieClearIdentity
): Record<string, unknown> {
const sameSite = cdpSameSite(identity.sameSite)
@@ -232,24 +247,34 @@ async function snapshotClearIdentitiesFromCdp(
return cookieClearIdentitiesFromCdp(cookies, cdpCookiesFromCommand(result))
}
async function writeIdentityWithCdp(
cookieDebugger: CookieClearDebugger,
identity: CookieClearIdentity,
failureLabel: string
): Promise<void> {
const result = await cookieDebugger.sendCommand(
'Network.setCookie',
cdpSetCookieParamsFromIdentity(identity)
)
// Why: Network.setCookie reports rejection in the reply rather than throwing, so an unchecked
// call reads as a successful write of a cookie that was never stored.
if (!cdpSetCookieSucceeded(result)) {
throw new Error(`Could not ${failureLabel} cookie ${identity.name}`)
}
}
async function restoreClearIdentitiesWithCdp(
cookieDebugger: CookieClearDebugger,
identities: readonly CookieClearIdentity[]
): Promise<void> {
for (const identity of identities) {
const result = await cookieDebugger.sendCommand(
'Network.setCookie',
cdpRestoreParamsFromIdentity(identity)
)
if (!cdpSetCookieSucceeded(result)) {
throw new Error(`Could not restore cookie ${identity.name}`)
}
await writeIdentityWithCdp(cookieDebugger, identity, 'restore')
}
}
export function openCookieClearStore(
targetSession: Session
): CookieClearStore & { dispose: () => void } {
): CookieClearStore & CookieImportWriteStore & { dispose: () => void } {
let attached: CookieClearSession | null = null
let pendingAttach: Promise<CookieClearSession> | null = null
let disposed = false
@@ -287,6 +312,8 @@ export function openCookieClearStore(
snapshotClearIdentitiesFromCdp((await attach()).debugger, cookies),
restoreClearIdentities: async (identities) =>
restoreClearIdentitiesWithCdp((await attach()).debugger, identities),
writeCookieIdentity: async (identity) =>
writeIdentityWithCdp((await attach()).debugger, identity, 'import'),
dispose: () => {
disposed = true
pendingAttach = null
@@ -35,6 +35,13 @@ export type CookieClearStore = Pick<Cookies, 'get' | 'remove'> & {
restoreClearIdentities(identities: readonly CookieClearIdentity[]): Promise<void>
}
// Why (STA-4300): the import writes go through this store, and 'set' stays out of it for the same
// reason it stays out of the clear path — cookies.set() drops partitionKey silently, so a CHIPS
// cookie imported through it is downgraded on the success path with nothing to report it.
export type CookieImportWriteStore = Pick<Cookies, 'get' | 'remove'> & {
writeCookieIdentity(identity: CookieClearIdentity): Promise<void>
}
// Why (STA-4061): 'set' stays out so the lossy partition-dropping reconstruction cannot return.
export type CookieClearSession = {
cookies: Pick<Cookies, 'get' | 'remove'>
@@ -40,6 +40,10 @@ vi.mock('./browser-cookie-clear-store', () => ({
}) => ({
get: (filter: object) => targetSession.cookies.get(filter),
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
// Why (STA-4300): the import writes go through CDP identities; route them to the same spy so
// a missing method cannot silently reroute every write down the rejected-cookie path.
writeCookieIdentity: (identity: Record<string, unknown>) =>
targetSession.cookies.set!(identity),
snapshotClearIdentities: async (
items: {
cookie: {
@@ -217,7 +221,8 @@ describe('native Chromium import excludes the Google cookie family', () => {
remove: cookiesRemoveMock,
set: cookiesSetMock
},
clearData: clearDataMock
clearData: clearDataMock,
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
})
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
})
@@ -0,0 +1,436 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFs from 'node:fs'
const {
appGetPathMock,
copyFileSyncMock,
execFileSyncMock,
sessionFromPartitionMock,
setPendingCookieImportMock,
clearPendingCookieImportMock,
writeCookieIdentityMock
} = vi.hoisted(() => ({
appGetPathMock: vi.fn(),
copyFileSyncMock: vi.fn(),
execFileSyncMock: vi.fn(),
sessionFromPartitionMock: vi.fn(),
setPendingCookieImportMock: vi.fn(),
clearPendingCookieImportMock: vi.fn(),
writeCookieIdentityMock: vi.fn()
}))
vi.mock('./browser-session-registry', () => ({
browserSessionRegistry: {
setPendingCookieImport: setPendingCookieImportMock,
clearPendingCookieImport: clearPendingCookieImportMock
}
}))
vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
return {
...actual,
copyFileSync: (...args: Parameters<typeof actual.copyFileSync>) => {
copyFileSyncMock(...args)
return actual.copyFileSync(...args)
}
}
})
vi.mock('electron', () => ({
app: { getPath: appGetPathMock },
BrowserWindow: { fromWebContents: vi.fn() },
dialog: { showOpenDialog: vi.fn() },
session: { fromPartition: sessionFromPartitionMock }
}))
vi.mock('./browser-cookie-clear-store', () => ({
openCookieClearStore: (targetSession: {
cookies: {
get: (filter: object) => Promise<unknown>
remove: (url: string, name: string) => Promise<void>
}
}) => ({
get: (filter: object) => targetSession.cookies.get(filter),
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
items.map(({ cookie, url }) => ({ url, ...cookie })),
restoreClearIdentities: async () => undefined,
// Why (STA-4300): the import writes land here, not on cookies.set. A store mock missing this
// method would throw a TypeError the per-cookie catch swallows, quietly routing every write
// down the rejected-cookie path while the suite still looked green.
writeCookieIdentity: writeCookieIdentityMock,
dispose: () => undefined
})
}))
import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import'
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { DatabaseSync } from 'node:sqlite'
import type { DetectedBrowser } from './browser-cookie-import'
function chromeBrowser(cookiesPath: string): DetectedBrowser {
return {
family: 'chrome',
label: 'Google Chrome',
cookiesPath,
keychainService: 'Chrome Safe Storage',
keychainAccount: 'Chrome',
profiles: [{ name: 'Default', directory: 'Default' }],
selectedProfile: 'Default'
}
}
function firefoxBrowser(cookiesPath: string): DetectedBrowser {
return {
family: 'firefox',
label: 'Firefox',
cookiesPath,
profiles: [{ name: 'default-release', directory: 'default-release' }],
selectedProfile: 'default-release'
}
}
// Why (STA-4300): cookies.set() silently drops partitionKey, so no user cookie may reach it. Only
// the __init probe — which writes no user data — is allowed through; anything else is the
// downgrade returning, and it must fail the test rather than quietly succeed.
const unreachableCookieSet = vi.fn(async (details: { name: string }) => {
if (details.name !== '__init') {
throw new Error(`cookies.set was called for user cookie ${details.name}`)
}
})
describe('validated import partition fidelity', () => {
let tmpDir: string
let cookieWriteMock: ReturnType<typeof vi.fn>
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-partition-fidelity-file-'))
cookieWriteMock = writeCookieIdentityMock
cookieWriteMock.mockReset()
cookieWriteMock.mockResolvedValue(undefined)
sessionFromPartitionMock.mockReset()
sessionFromPartitionMock.mockReturnValue({
cookies: {
get: vi.fn().mockResolvedValue([]),
remove: vi.fn().mockResolvedValue(undefined),
set: unreachableCookieSet
}
})
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
function writeCookieFile(cookies: unknown[]): string {
const filePath = join(tmpDir, 'cookies.json')
writeFileSync(filePath, JSON.stringify(cookies))
return filePath
}
// Why (STA-4300): a JSON export that carries a full CHIPS partition must reach the jar with it.
// cookies.set() drops partitionKey silently, so this only works through the CDP identity store.
it('carries a complete partitionKey from a JSON export through to the write', async () => {
const filePath = writeCookieFile([
{
domain: '.app.example',
name: 'chips-auth',
value: 'keep-me',
secure: true,
sameSite: 'None',
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
}
])
const result = await importCookiesFromFile(filePath, 'persist:test')
expect(result.ok).toBe(true)
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
expect(cookieWriteMock.mock.calls[0][0]).toMatchObject({
name: 'chips-auth',
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
})
expect(result.ok && result.summary.partitionSkippedCookies).toBeUndefined()
})
// Why (STA-4300): exporters that emit only topLevelSite carry no ancestor bit. Writing the cookie
// unpartitioned would report a clean success the site cannot use, so it is skipped and counted.
it('skips and reports a cookie whose partitionKey cannot be read faithfully', async () => {
const filePath = writeCookieFile([
{
domain: '.app.example',
name: 'chips-auth',
value: 'keep-me',
secure: true,
partitionKey: { topLevelSite: 'https://top.example' }
},
{ domain: '.plain.example', name: 'plain', value: 'ok' }
])
const result = await importCookiesFromFile(filePath, 'persist:test')
expect(result.ok).toBe(true)
// The lossy write is not merely unused — only the readable cookie was ever attempted.
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
expect(cookieWriteMock.mock.calls[0][0].name).toBe('plain')
expect(result.ok && result.summary.partitionSkippedCookies).toBe(1)
expect(result.ok && result.summary.importedCookies).toBe(1)
expect(result.ok && result.summary.skippedCookies).toBe(1)
expect(result.ok && result.summary.domains).toEqual(['plain.example'])
})
it('does not replace existing cookies for a domain whose only source cookie is skipped', async () => {
const remove = vi.fn().mockResolvedValue(undefined)
sessionFromPartitionMock.mockReturnValue({
cookies: {
get: vi.fn().mockResolvedValue([
{
name: 'existing-session',
value: 'still-valid',
domain: '.app.example',
path: '/',
secure: true,
httpOnly: true,
hostOnly: false,
session: true,
sameSite: 'lax'
}
]),
remove,
set: unreachableCookieSet
}
})
const filePath = writeCookieFile([
{
domain: '.app.example',
name: 'chips-auth',
value: 'keep-me',
secure: true,
partitionKey: { topLevelSite: 'https://top.example' }
}
])
const result = await importCookiesFromFile(filePath, 'persist:test')
expect(result.ok && result.summary.partitionSkippedCookies).toBe(1)
expect(cookieWriteMock).not.toHaveBeenCalled()
expect(remove).not.toHaveBeenCalled()
})
})
describe('native Chromium import partition fidelity', () => {
let tmpDir: string
let cookieWriteMock: ReturnType<typeof vi.fn>
let platformSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-partition-fidelity-native-'))
cookieWriteMock = writeCookieIdentityMock
cookieWriteMock.mockReset()
cookieWriteMock.mockResolvedValue(undefined)
appGetPathMock.mockReset().mockReturnValue(join(tmpDir, 'userData'))
copyFileSyncMock.mockClear()
setPendingCookieImportMock.mockClear()
clearPendingCookieImportMock.mockClear()
execFileSyncMock.mockReset().mockImplementation(() => {
throw new Error('OS credential commands are unavailable in this test')
})
sessionFromPartitionMock.mockReset().mockReturnValue({
cookies: {
get: vi.fn().mockResolvedValue([]),
set: unreachableCookieSet,
remove: vi.fn().mockResolvedValue(undefined),
flushStore: vi.fn().mockResolvedValue(undefined)
},
clearData: vi.fn().mockResolvedValue(undefined),
setUserAgent: vi.fn(),
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
})
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
})
afterEach(() => {
platformSpy.mockRestore()
rmSync(tmpDir, { recursive: true, force: true })
})
// Why (STA-4300): the native path's in-memory load used cookies.set(), which drops partitionKey
// silently. This pins the identity that actually reaches the jar; the end-to-end proof that
// Chromium stores it is browser-cookie-import-partition-success.electron.test.ts.
it('carries a partitioned source row through to the write with both partition halves', async () => {
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
createChromiumCookieTestDatabase(sourceCookiesPath, [
{
domain: '.app.acme-chips.test',
name: 'chips-auth',
value: 'keep-me',
isSecure: 1,
sameSite: 1,
topFrameSiteKey: 'https://top.example',
hasCrossSiteAncestor: 1
},
{ domain: '.plain.example', name: 'plain', value: 'plain-ok', isSecure: 1 }
]).close()
createChromiumCookieTestDatabase(
join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'),
[]
).close()
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
expect(result.ok).toBe(true)
expect(cookieWriteMock).toHaveBeenCalledTimes(2)
const written = cookieWriteMock.mock.calls.map((call) => call[0])
expect(written.find((cookie) => cookie.name === 'chips-auth')).toMatchObject({
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
})
expect(written.find((cookie) => cookie.name === 'plain')).not.toHaveProperty('partitionKey')
expect(result.ok && result.summary?.partitionSkippedCookies).toBeUndefined()
})
it('never stages a partitioned row whose ancestor bit is unreadable', async () => {
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
mkdirSync(dirname(sourceCookiesPath), { recursive: true })
const legacyDb = new DatabaseSync(sourceCookiesPath)
legacyDb.exec(`
CREATE TABLE cookies (
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
top_frame_site_key TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
value TEXT NOT NULL,
encrypted_value BLOB NOT NULL DEFAULT X'',
path TEXT NOT NULL,
expires_utc INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
is_httponly INTEGER NOT NULL,
samesite INTEGER NOT NULL,
source_scheme INTEGER NOT NULL DEFAULT 0,
source_port INTEGER NOT NULL DEFAULT -1,
last_update_utc INTEGER NOT NULL DEFAULT 0
)
`)
legacyDb.exec(`
INSERT INTO cookies VALUES
(133000000000000, '.app.acme-chips.test', 'https://top.example', 'chips-auth', 'keep-me', X'', '/', 0, 1, 0, 1, 0, -1, 0),
(133000000000001, '.plain.example', '', 'plain', 'plain-ok', X'', '/', 0, 1, 0, 0, 0, -1, 0)
`)
legacyDb.close()
createChromiumCookieTestDatabase(
join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'),
[]
).close()
cookieWriteMock.mockRejectedValueOnce(new Error('plain cookie needs restart'))
const blocked = await importCookiesFromBrowser(
chromeBrowser(sourceCookiesPath),
'persist:test',
{
canReportPartitionSkippedCookies: false
}
)
expect(blocked.ok).toBe(false)
expect(blocked.ok || blocked.reason).toContain('cannot report')
expect(cookieWriteMock).not.toHaveBeenCalled()
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
const blockedSession = sessionFromPartitionMock.mock.results[0].value
expect(blockedSession.clearData).not.toHaveBeenCalled()
expect(blockedSession.cookies.remove).not.toHaveBeenCalled()
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
expect(result.ok).toBe(true)
// Only the readable cookie was ever attempted — the unreadable one reached no writer at all.
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
expect(cookieWriteMock.mock.calls[0][0].name).toBe('plain')
expect(result.ok && result.summary?.partitionSkippedCookies).toBe(1)
expect(result.ok && result.summary?.importedCookies).toBe(1)
expect(result.ok && result.summary?.skippedCookies).toBe(1)
expect(setPendingCookieImportMock).toHaveBeenCalledOnce()
const stagedPath = setPendingCookieImportMock.mock.calls[0][1] as string
const stagedDb = new DatabaseSync(stagedPath, { readOnly: true })
const stagedNames = stagedDb.prepare('SELECT name FROM cookies ORDER BY name').all()
stagedDb.close()
expect(stagedNames).toEqual([{ name: 'plain' }])
})
})
describe('Firefox import partition fidelity', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-partition-fidelity-firefox-'))
writeCookieIdentityMock.mockReset().mockResolvedValue(undefined)
sessionFromPartitionMock.mockReset()
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
it('leaves the target jar untouched when the client cannot report a partition skip', async () => {
const sourceCookiesPath = join(tmpDir, 'cookies.sqlite')
const sourceDb = new DatabaseSync(sourceCookiesPath)
sourceDb.exec(`
CREATE TABLE moz_cookies (
name TEXT,
value TEXT,
host TEXT,
path TEXT,
expiry INTEGER,
isSecure INTEGER,
isHttpOnly INTEGER,
sameSite INTEGER,
originAttributes TEXT,
isPartitionedAttributeSet INTEGER
);
INSERT INTO moz_cookies VALUES
('chips-auth', 'keep-me', '.app.example', '/', 0, 1, 1, 0,
'^partitionKey=(https,top.example)', 1),
('dfpi', 'dfpi-ok', '.dfpi.example', '/', 0, 1, 0, 0,
'^partitionKey=(https,top.example,f)', 0)
`)
sourceDb.close()
const get = vi.fn().mockResolvedValue([
{
name: 'existing-session',
value: 'still-valid',
domain: '.app.example',
path: '/',
secure: true,
httpOnly: true,
hostOnly: false,
session: true,
sameSite: 'lax'
}
])
const remove = vi.fn().mockResolvedValue(undefined)
sessionFromPartitionMock.mockReturnValue({ cookies: { get, remove } })
const result = await importCookiesFromBrowser(
firefoxBrowser(sourceCookiesPath),
'persist:test',
{ canReportPartitionSkippedCookies: false }
)
expect(result.ok).toBe(false)
expect(result.ok || result.reason).toContain('cannot report')
expect(get).not.toHaveBeenCalled()
expect(remove).not.toHaveBeenCalled()
expect(writeCookieIdentityMock).not.toHaveBeenCalled()
get.mockResolvedValue([])
const supportedResult = await importCookiesFromBrowser(
firefoxBrowser(sourceCookiesPath),
'persist:test'
)
expect(supportedResult.ok).toBe(true)
expect(supportedResult.ok && supportedResult.summary?.partitionSkippedCookies).toBe(1)
expect(supportedResult.ok && supportedResult.summary?.importedCookies).toBe(1)
expect(writeCookieIdentityMock).toHaveBeenCalledOnce()
expect(writeCookieIdentityMock.mock.calls[0][0].name).toBe('dfpi')
})
})
@@ -0,0 +1,252 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterAll, describe, expect, it } from 'vitest'
import { build as buildVite } from 'vite'
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
const electronBinary = createRequire(import.meta.url)('electron') as string
const fixtureRoots: string[] = []
afterAll(() => {
for (const root of fixtureRoots) {
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})
type CdpCookie = {
name: string
value: string
partitionKey: Record<string, unknown> | null
}
type ImportResult = {
ok: boolean
reason?: string
summary?: {
totalCookies: number
importedCookies: number
skippedCookies: number
domains: string[]
}
}
type FixtureResult = {
step: string
error?: string
beforeCookieCount: number
importResult: ImportResult
afterChips: CdpCookie[]
afterPlain: CdpCookie[]
}
type SourceChipsRow = {
host_key: string
name: string
value: string
top_frame_site_key: string
has_cross_site_ancestor: number
}
// Why: the correct behavior — the CDP object form Network.setCookie writes and
// Network.getAllCookies returns, as proven by the rollback fixture on this Electron.
const EXPECTED_PARTITION_KEY = {
topLevelSite: 'https://top.example',
hasCrossSiteAncestor: true
}
const SOURCE_DB_RELATIVE_PATH = 'source-cookies.db'
function buildFixtureMain(bundlePath: string, resultPath: string, sourceDbPath: string): string {
return `
const { app, BrowserWindow, session } = require('electron')
const { writeFileSync } = require('node:fs')
const { importCookiesFromBrowser } = require(${JSON.stringify(bundlePath)})
const resultPath = ${JSON.stringify(resultPath)}
let currentStep = 'starting'
const mark = (step) => {
currentStep = step
writeFileSync(resultPath, JSON.stringify({ step }))
}
async function run() {
const timeout = setTimeout(() => {
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
app.exit(1)
}, 30000)
await app.whenReady()
mark('ready')
const partition = 'persist:partition-success-cookie-test'
const targetSession = session.fromPartition(partition)
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
mark('window created')
await window.loadURL('data:text/html,<title>cookie partition success fixture</title>')
mark('window loaded')
const debug = window.webContents.debugger
debug.attach('1.3')
mark('debugger attached')
// Why: only CDP can observe partitionKey; prove the jar is empty before the import
// so every cookie read afterwards can only have come from the import itself.
const beforeCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
mark('jar read before import')
const importResult = await importCookiesFromBrowser(
{
family: 'chrome',
label: 'Google Chrome',
cookiesPath: ${JSON.stringify(sourceDbPath)},
profiles: [],
selectedProfile: ''
},
partition
)
mark('import finished')
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
const project = (name) => afterCookies
.filter((cookie) => cookie.name === name)
.map((cookie) => ({
name: cookie.name,
value: cookie.value,
partitionKey: cookie.partitionKey ?? null
}))
clearTimeout(timeout)
writeFileSync(resultPath, JSON.stringify({
step: currentStep,
beforeCookieCount: beforeCookies.length,
importResult,
afterChips: project('chips-auth'),
afterPlain: project('plain')
}))
debug.detach()
window.destroy()
app.exit(0)
}
run().catch((error) => {
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
app.exit(1)
})
`
}
function readSourceChipsRow(sourceDbPath: string): SourceChipsRow {
const db = new DatabaseSync(sourceDbPath, { readOnly: true })
try {
const rows = db
.prepare(
'SELECT host_key, name, value, top_frame_site_key, has_cross_site_ancestor FROM cookies ORDER BY rowid'
)
.all() as SourceChipsRow[]
const row = rows.find((candidate) => candidate.name === 'chips-auth')
if (!row) {
throw new Error('source DB has no chips-auth row')
}
return row
} finally {
db.close()
}
}
async function runFixture(): Promise<{ fixture: FixtureResult; sourceChips: SourceChipsRow }> {
const root = mkdtempSync(join(tmpdir(), 'orca-partition-success-'))
fixtureRoots.push(root)
const bundlePath = join(root, 'cookie-import-success.cjs')
const bundleEntryPath = join(root, 'cookie-import-success.ts')
const resultPath = join(root, 'result.json')
const fixturePath = join(root, 'main.cjs')
const sourceDbPath = join(root, SOURCE_DB_RELATIVE_PATH)
const sourceDb = createChromiumCookieTestDatabase(sourceDbPath, [
// Why: the CHIPS row is a genuine partitioned cookie: top_frame_site_key names the
// top-level site and has_cross_site_ancestor=1 means it was set cross-site.
{
domain: '.app.acme-chips.test',
name: 'chips-auth',
value: 'keep-me',
isSecure: 1,
sameSite: 1,
topFrameSiteKey: EXPECTED_PARTITION_KEY.topLevelSite,
hasCrossSiteAncestor: 1
},
// Why: an ordinary row with identical attributes minus the partition columns proves
// the import still works for the unpartitioned case.
{
domain: '.plain.example',
name: 'plain',
value: 'plain-ok',
isSecure: 1,
sameSite: 1
}
])
sourceDb.close()
const sourceChips = readSourceChipsRow(sourceDbPath)
writeFileSync(
bundleEntryPath,
`export { importCookiesFromBrowser } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'))}`
)
await buildVite({
configFile: false,
logLevel: 'silent',
build: {
emptyOutDir: false,
lib: {
entry: bundleEntryPath,
formats: ['cjs'],
fileName: () => 'cookie-import-success.cjs'
},
outDir: root,
target: 'node20',
rollupOptions: { external: ['electron', /^node:/] }
}
})
writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, sourceDbPath))
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
const args =
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs
const run = spawnSync(executable, args, {
encoding: 'utf8',
env,
timeout: 90_000
})
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
expect(run.error).toBeUndefined()
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
return { fixture: JSON.parse(fixtureResult) as FixtureResult, sourceChips }
}
describe('STA-4300: partitioned (CHIPS) cookies on the native Chromium import success path', () => {
it('preserves the source partitionKey through importCookiesFromBrowser', async () => {
const { fixture, sourceChips } = await runFixture()
// Non-vacuity breadcrumbs: the fixture reached the import, the source row really was
// partitioned before import, and the import reported success with both cookies.
expect(fixture.step).toBe('import finished')
expect(fixture.beforeCookieCount).toBe(0)
expect(sourceChips).toEqual({
host_key: '.app.acme-chips.test',
name: 'chips-auth',
value: 'keep-me',
top_frame_site_key: 'https://top.example',
has_cross_site_ancestor: 1
})
expect(fixture.importResult.ok).toBe(true)
expect(fixture.importResult.summary?.importedCookies).toBe(2)
// Ordinary unpartitioned import still works end to end.
expect(fixture.afterPlain).toEqual([{ name: 'plain', value: 'plain-ok', partitionKey: null }])
// The partitioned cookie must keep its partition — cookies.set() silently drops it, so
// this is the assertion that fails on current main and goes green once writes go through CDP.
expect(fixture.afterChips).toHaveLength(1)
expect(fixture.afterChips[0].value).toBe('keep-me')
expect(fixture.afterChips[0].partitionKey).toEqual(EXPECTED_PARTITION_KEY)
}, 120_000)
})
@@ -38,10 +38,15 @@ vi.mock('./browser-cookie-clear-store', () => ({
cookies: {
get: (filter: object) => Promise<unknown>
remove: (url: string, name: string) => Promise<void>
set?: (details: Record<string, unknown>) => Promise<void>
}
}) => ({
get: (filter: object) => targetSession.cookies.get(filter),
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
// Why (STA-4300): the import writes go through CDP identities; route them to the same spy so
// a missing method cannot silently reroute every write down the rejected-cookie path.
writeCookieIdentity: (identity: Record<string, unknown>) =>
targetSession.cookies.set!(identity),
snapshotClearIdentities: snapshotClearIdentitiesMock,
restoreClearIdentities: restoreClearIdentitiesMock,
dispose: disposeClearStoreMock
@@ -248,7 +253,8 @@ describe('native Chromium integrity-cookie accounting', () => {
remove: vi.fn().mockResolvedValue(undefined),
set: cookiesSetMock
},
clearData: clearDataMock
clearData: clearDataMock,
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
})
})
@@ -8,6 +8,12 @@ type ChromiumCookieTestRow = {
name: string
value: string
encryptedValue?: Buffer
// Why: default '' matches a real unpartitioned row; CHIPS fixtures set a top-level site.
topFrameSiteKey?: string
hasCrossSiteAncestor?: 0 | 1
isSecure?: 0 | 1
isHttpOnly?: 0 | 1
sameSite?: 0 | 1 | 2 | 3
}
export function createChromiumCookieTestDatabase(
@@ -57,16 +63,21 @@ export function createChromiumCookieTestDatabase(
source_port,
last_update_utc,
has_cross_site_ancestor
) VALUES (?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0, 0, -1, ?, 0)
) VALUES (?, ?, ?, ?, ?, ?, '/', 0, ?, ?, ?, 0, -1, ?, ?)
`)
rows.forEach((row, index) => {
insert.run(
133_000_000_000_000 + index,
row.domain ?? '.example.com',
row.topFrameSiteKey ?? '',
row.name,
row.value,
row.encryptedValue ?? Buffer.alloc(0),
0
row.isSecure ?? 0,
row.isHttpOnly ?? 0,
row.sameSite ?? 0,
0,
row.hasCrossSiteAncestor ?? 0
)
})
return database
@@ -0,0 +1,231 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { build as buildVite } from 'vite'
const electronBinary = createRequire(import.meta.url)('electron') as string
const fixtureRoots: string[] = []
afterAll(() => {
for (const root of fixtureRoots) {
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})
type CdpCookie = {
name: string
value: string
partitionKey: Record<string, unknown> | null
}
type FixtureResult = {
step: string
error?: string
beforeCookieCount: number
importResult: {
ok: boolean
reason?: string
summary?: {
importedCookies: number
skippedCookies: number
partitionSkippedCookies?: number
domains: string[]
}
}
afterChips: CdpCookie[]
afterHostChips: CdpCookie[]
afterPlain: CdpCookie[]
afterUnreadable: CdpCookie[]
}
const EXPECTED_PARTITION_KEY = {
topLevelSite: 'https://top.example',
hasCrossSiteAncestor: true
}
// Why (STA-4300): the JSON/paste import writes through the same CDP identity store as the native
// one. These four cookies are the failure-mode table's validated-path row: ordinary, host-prefixed,
// partitioned, and a partition the exporter described incompletely.
const SOURCE_COOKIES = [
{
domain: '.app.acme-chips.test',
name: 'chips-auth',
value: 'keep-me',
path: '/',
secure: true,
sameSite: 'None',
partitionKey: EXPECTED_PARTITION_KEY
},
{
domain: 'host.acme-chips.test',
name: '__Host-chips-session',
value: 'host-keep-me',
path: '/',
secure: true,
sameSite: 'None',
partitionKey: EXPECTED_PARTITION_KEY
},
{
domain: '.plain.example',
name: 'plain',
value: 'plain-ok',
path: '/',
secure: true
},
{
// Only topLevelSite — the shape exporters emit without the cross-site-ancestor bit.
domain: '.partial.example',
name: 'partial-chips',
value: 'must-not-land',
path: '/',
secure: true,
sameSite: 'None',
partitionKey: { topLevelSite: 'https://top.example' }
}
]
function buildFixtureMain(bundlePath: string, resultPath: string, cookieFilePath: string): string {
return `
const { app, BrowserWindow, session } = require('electron')
const { writeFileSync } = require('node:fs')
const { importCookiesFromFile } = require(${JSON.stringify(bundlePath)})
const resultPath = ${JSON.stringify(resultPath)}
let currentStep = 'starting'
const mark = (step) => {
currentStep = step
writeFileSync(resultPath, JSON.stringify({ step }))
}
async function run() {
const timeout = setTimeout(() => {
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
app.exit(1)
}, 30000)
await app.whenReady()
mark('ready')
const partition = 'persist:validated-partition-cookie-test'
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
mark('window created')
await window.loadURL('data:text/html,<title>validated partition fixture</title>')
mark('window loaded')
const debug = window.webContents.debugger
debug.attach('1.3')
mark('debugger attached')
// Why: only CDP can observe partitionKey; an empty jar first means every cookie read
// afterwards can only have come from the import.
const beforeCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
mark('jar read before import')
const importResult = await importCookiesFromFile(${JSON.stringify(cookieFilePath)}, partition)
mark('import finished')
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
const project = (name) => afterCookies
.filter((cookie) => cookie.name === name)
.map((cookie) => ({
name: cookie.name,
value: cookie.value,
partitionKey: cookie.partitionKey ?? null
}))
clearTimeout(timeout)
writeFileSync(resultPath, JSON.stringify({
step: currentStep,
beforeCookieCount: beforeCookies.length,
importResult,
afterChips: project('chips-auth'),
afterHostChips: project('__Host-chips-session'),
afterPlain: project('plain'),
afterUnreadable: project('partial-chips')
}))
debug.detach()
window.destroy()
app.exit(0)
}
run().catch((error) => {
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
app.exit(1)
})
`
}
async function runFixture(): Promise<FixtureResult> {
const root = mkdtempSync(join(tmpdir(), 'orca-validated-partition-'))
fixtureRoots.push(root)
const bundlePath = join(root, 'cookie-import-validated.cjs')
const bundleEntryPath = join(root, 'cookie-import-validated.ts')
const resultPath = join(root, 'result.json')
const fixturePath = join(root, 'main.cjs')
const cookieFilePath = join(root, 'cookies.json')
writeFileSync(cookieFilePath, JSON.stringify(SOURCE_COOKIES))
writeFileSync(
bundleEntryPath,
`export { importCookiesFromFile } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'))}`
)
await buildVite({
configFile: false,
logLevel: 'silent',
build: {
emptyOutDir: false,
lib: {
entry: bundleEntryPath,
formats: ['cjs'],
fileName: () => 'cookie-import-validated.cjs'
},
outDir: root,
target: 'node20',
rollupOptions: { external: ['electron', /^node:/] }
}
})
writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, cookieFilePath))
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
const args =
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 90_000 })
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
expect(run.error).toBeUndefined()
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
return JSON.parse(fixtureResult) as FixtureResult
}
describe('STA-4300: partitioned cookies on the validated (file/paste) import success path', () => {
it('stores CHIPS and __Host- CHIPS partitioned, and skips an unreadable partition', async () => {
const result = await runFixture()
// Non-vacuity breadcrumbs: the fixture reached the import and the jar was empty before it.
expect(result.step).toBe('import finished')
expect(result.beforeCookieCount).toBe(0)
expect(result.importResult.ok).toBe(true)
// Ordinary cookie: unchanged behavior, no partition invented for it.
expect(result.afterPlain).toEqual([{ name: 'plain', value: 'plain-ok', partitionKey: null }])
// Partitioned cookie: Chromium stored the partition the export declared.
expect(result.afterChips).toEqual([
{ name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY }
])
// A host-prefixed cookie can also be partitioned; the __Host- rules must not drop the partition.
expect(result.afterHostChips).toEqual([
{
name: '__Host-chips-session',
value: 'host-keep-me',
partitionKey: EXPECTED_PARTITION_KEY
}
])
// The incompletely-described partition was skipped, not downgraded to an unpartitioned cookie.
expect(result.afterUnreadable).toEqual([])
expect(result.importResult.summary?.partitionSkippedCookies).toBe(1)
expect(result.importResult.summary?.importedCookies).toBe(3)
expect(result.importResult.summary?.domains).not.toContain('partial.example')
}, 120_000)
})
@@ -0,0 +1,199 @@
import { describe, expect, it, vi } from 'vitest'
import type { CookieClearIdentity } from './browser-cookie-import-clear'
import type { SourcePartitionRead } from './browser-cookie-source-partition'
import { writeImportedCookies, type SourceCookieToWrite } from './browser-cookie-import-write'
const PARTITION_KEY = { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
function sourceCookie(
overrides: Partial<SourceCookieToWrite> & { name: string; partition: SourcePartitionRead }
): SourceCookieToWrite {
return {
url: 'https://app.example/',
value: 'v',
domain: '.app.example',
path: '/',
secure: true,
httpOnly: false,
sameSite: 'no_restriction',
expirationDate: undefined,
...overrides
}
}
function recordingStore() {
const writeCookieIdentity = vi.fn<(identity: CookieClearIdentity) => Promise<void>>(
async () => undefined
)
return { writeCookieIdentity }
}
const silentOptions = { stopOnFailure: false, log: () => undefined }
describe('writeImportedCookies', () => {
it('writes a partitioned cookie through the identity store with its partition key intact', async () => {
const store = recordingStore()
const phase = await writeImportedCookies(
store,
[
sourceCookie({
name: 'chips',
partition: { status: 'partitioned', partitionKey: PARTITION_KEY }
})
],
silentOptions
)
// Why: assert the write was ATTEMPTED, not just that the counters look right — a store mock
// missing this method would throw a TypeError the catch swallows, and every counter below
// would still be reachable through the rejected path.
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
expect(store.writeCookieIdentity.mock.calls[0][0]).toMatchObject({
name: 'chips',
partitionKey: PARTITION_KEY
})
expect(phase.importedCount).toBe(1)
expect(phase.partitionSkipped).toBe(0)
expect(phase.writeRejected).toBe(0)
})
it('writes an ordinary cookie with no partitionKey attribute at all', async () => {
const store = recordingStore()
await writeImportedCookies(
store,
[sourceCookie({ name: 'plain', partition: { status: 'unpartitioned' } })],
silentOptions
)
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
expect(store.writeCookieIdentity.mock.calls[0][0]).not.toHaveProperty('partitionKey')
})
// Why (STA-4300): the whole point of the change — an unreadable partition must never reach the
// store as an unpartitioned write.
it('never writes a cookie whose partition could not be read, and counts it', async () => {
const store = recordingStore()
const logged: string[] = []
const phase = await writeImportedCookies(
store,
[
sourceCookie({ name: 'chips', partition: { status: 'unreadable', reason: 'no ancestor' } }),
sourceCookie({ name: 'plain', partition: { status: 'unpartitioned' } })
],
{ stopOnFailure: false, log: (message) => logged.push(message) }
)
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
expect(store.writeCookieIdentity.mock.calls[0][0].name).toBe('plain')
expect(phase.partitionSkipped).toBe(1)
expect(phase.importedCount).toBe(1)
expect(phase.failure).toBeNull()
expect(logged.join('\n')).toContain('unreadable partition')
})
// Why: cookie values are secrets; a skip line that echoed one would leak it into the diag log.
it('logs only the domain for a skipped cookie, never its name or value', async () => {
const logged: string[] = []
await writeImportedCookies(
recordingStore(),
[
sourceCookie({
name: 'session-token',
value: 'super-secret',
partition: { status: 'unreadable', reason: 'no ancestor' }
})
],
{ stopOnFailure: false, log: (message) => logged.push(message) }
)
const line = logged.join('\n')
expect(line).toContain('app.example')
expect(line).not.toContain('super-secret')
expect(line).not.toContain('session-token')
})
it('writes __Host- cookies host-only at the root path so Chromium accepts them', async () => {
const store = recordingStore()
await writeImportedCookies(
store,
[
sourceCookie({
name: '__Host-session',
path: '/deep',
partition: { status: 'partitioned', partitionKey: PARTITION_KEY }
})
],
silentOptions
)
expect(store.writeCookieIdentity.mock.calls[0][0]).toMatchObject({
hostOnly: true,
path: '/',
// A host-prefixed cookie can still be partitioned; the prefix must not drop the partition.
partitionKey: PARTITION_KEY
})
})
// Why: once existing cookies have been removed, the caller has to roll back, so the run must stop
// at the first rejection instead of writing over a jar it is about to restore.
it('stops at the first rejection when the caller must roll back', async () => {
const store = recordingStore()
store.writeCookieIdentity.mockRejectedValueOnce(new Error('rejected'))
const phase = await writeImportedCookies(
store,
[
sourceCookie({ name: 'first', partition: { status: 'unpartitioned' } }),
sourceCookie({ name: 'second', partition: { status: 'unpartitioned' } })
],
{ stopOnFailure: true, log: () => undefined }
)
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
expect(phase.writeRejected).toBe(1)
expect(phase.importedCount).toBe(0)
expect(phase.failure).toBeInstanceOf(Error)
})
// Why: the native path has a staged cold-start replay behind it, so one rejected cookie must not
// stop the rest from loading.
it('keeps going past a rejection when the caller has a restart fallback', async () => {
const store = recordingStore()
store.writeCookieIdentity.mockRejectedValueOnce(new Error('rejected'))
const phase = await writeImportedCookies(
store,
[
sourceCookie({ name: 'first', partition: { status: 'unpartitioned' } }),
sourceCookie({ name: 'second', partition: { status: 'unpartitioned' } })
],
silentOptions
)
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(2)
expect(phase.writeRejected).toBe(1)
expect(phase.importedCount).toBe(1)
})
it('reports the removal key at the identity path so a rollback can undo the write', async () => {
const phase = await writeImportedCookies(
recordingStore(),
[
sourceCookie({
name: 'scoped',
url: 'https://app.example/',
path: '/settings',
partition: { status: 'unpartitioned' }
})
],
silentOptions
)
expect(phase.importedKeys).toEqual([{ url: 'https://app.example/settings', name: 'scoped' }])
})
})
@@ -0,0 +1,163 @@
import type { Cookie } from 'electron'
import type {
CookieClearIdentity,
CookieClearPartitionKey,
CookieImportWriteStore
} from './browser-cookie-import-clear'
import type { SourcePartitionRead } from './browser-cookie-source-partition'
export type ImportedCookieFields = {
url: string
name: string
value: string
domain: string
path: string
secure: boolean
httpOnly: boolean
sameSite: Cookie['sameSite']
expirationDate: number | undefined
}
export type ImportWritePlan =
| { status: 'write'; identity: CookieClearIdentity }
| { status: 'skip'; reason: string }
const HOST_PREFIX = '__Host-'
export function importedCookieIdentity(
cookie: ImportedCookieFields,
partitionKey: CookieClearPartitionKey | undefined
): CookieClearIdentity {
// Why: Chromium rejects __Host- cookies unless they omit domain and use path=/; hostOnly is how
// the identity says "omit domain", the same contract the CDP restore params already read.
const isHostPrefixed = cookie.name.startsWith(HOST_PREFIX)
return {
url: cookie.url,
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
hostOnly: isHostPrefixed,
path: isHostPrefixed ? '/' : cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }),
...(partitionKey ? { partitionKey } : {})
}
}
/**
* Decides how one source cookie is written.
*
* Why (STA-4300): a cookie whose partition identity is unreadable is skipped and counted, never
* written unpartitioned. Downgrading it would import a cookie the site cannot see while reporting a
* clean success — the silent-loss shape behind STA-4013/4061/4090/4170.
*/
export function planImportedCookieWrite(
cookie: ImportedCookieFields,
partition: SourcePartitionRead
): ImportWritePlan {
if (partition.status === 'unreadable') {
return { status: 'skip', reason: partition.reason }
}
return {
status: 'write',
identity: importedCookieIdentity(
cookie,
partition.status === 'partitioned' ? partition.partitionKey : undefined
)
}
}
// Why: the rollback removes by coordinate, and remove() is path-sensitive, so the key has to use
// the identity's resolved path rather than the source cookie's.
export function importedCookieRemovalKey(identity: CookieClearIdentity): {
url: string
name: string
} {
const removalUrl = new URL(identity.url)
const path = identity.path ?? '/'
removalUrl.pathname = path.startsWith('/') ? path : '/'
return { url: removalUrl.toString(), name: identity.name }
}
export type SourceCookieToWrite = ImportedCookieFields & { partition: SourcePartitionRead }
export type ImportWritePhase = {
importedKeys: { url: string; name: string }[]
importedCount: number
writeRejected: number
partitionSkipped: number
domains: Set<string>
failure: unknown
}
export function emptyImportWritePhase(): ImportWritePhase {
return {
importedKeys: [],
importedCount: 0,
writeRejected: 0,
partitionSkipped: 0,
domains: new Set<string>(),
failure: null
}
}
// Why: cookie values are secret; only the domain is ever logged or summarized.
function summaryDomain(domain: string): string {
return domain.startsWith('.') ? domain.slice(1) : domain
}
function firstNonPrintable(value: string): string {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code < 0x20 || code > 0x7e) {
return `pos=${index} char=U+${code.toString(16).padStart(4, '0')}`
}
}
return 'none found'
}
/**
* Writes one import's cookies through the CDP identity store.
*
* `stopOnFailure` mirrors the replace path's contract: once existing cookies have been removed, the
* first rejection has to stop the run so the caller can roll the whole thing back.
*/
export async function writeImportedCookies(
store: Pick<CookieImportWriteStore, 'writeCookieIdentity'>,
cookies: readonly SourceCookieToWrite[],
options: { stopOnFailure: boolean; log: (message: string) => void }
): Promise<ImportWritePhase> {
const phase = emptyImportWritePhase()
for (const cookie of cookies) {
const plan = planImportedCookieWrite(cookie, cookie.partition)
if (plan.status === 'skip') {
phase.partitionSkipped += 1
options.log(
` cookie skipped, unreadable partition: domain=${summaryDomain(cookie.domain)} ${plan.reason}`
)
continue
}
try {
await store.writeCookieIdentity(plan.identity)
phase.importedKeys.push(importedCookieRemovalKey(plan.identity))
phase.importedCount += 1
phase.domains.add(summaryDomain(cookie.domain))
} catch (err) {
phase.writeRejected += 1
phase.failure = err
if (phase.writeRejected <= 5) {
options.log(
` cookie write REJECTED: domain=${summaryDomain(cookie.domain)} valLen=${cookie.value.length} badChar=${firstNonPrintable(cookie.value)} err=${String(err)}`
)
}
if (options.stopOnFailure) {
break
}
}
}
return phase
}
+62 -37
View File
@@ -8,7 +8,8 @@ const {
sessionFromPartitionMock,
dialogShowOpenDialogMock,
setPendingCookieImportMock,
clearPendingCookieImportMock
clearPendingCookieImportMock,
writeCookieIdentityMock
} = vi.hoisted(() => ({
appGetPathMock: vi.fn(),
copyFileSyncMock: vi.fn(),
@@ -16,7 +17,8 @@ const {
sessionFromPartitionMock: vi.fn(),
dialogShowOpenDialogMock: vi.fn(),
setPendingCookieImportMock: vi.fn(),
clearPendingCookieImportMock: vi.fn()
clearPendingCookieImportMock: vi.fn(),
writeCookieIdentityMock: vi.fn()
}))
vi.mock('./browser-session-registry', () => ({
@@ -56,6 +58,10 @@ vi.mock('./browser-cookie-clear-store', () => ({
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
items.map(({ cookie, url }) => ({ url, ...cookie })),
restoreClearIdentities: async () => undefined,
// Why (STA-4300): the import writes land here, not on cookies.set. A store mock missing this
// method would throw a TypeError the per-cookie catch swallows, quietly turning every write
// into a "rejected cookie" while the suite still looked green.
writeCookieIdentity: writeCookieIdentityMock,
dispose: () => undefined
})
}))
@@ -97,6 +103,15 @@ function chromeBrowser(cookiesPath: string): DetectedBrowser {
}
}
// Why (STA-4300): cookies.set() silently drops partitionKey, so no user cookie may reach it. Only
// the __init probe — which writes no user data — is allowed through; anything else is the
// downgrade returning, and it must fail the test rather than quietly succeed.
const unreachableCookieSet = vi.fn(async (details: { name: string }) => {
if (details.name !== '__init') {
throw new Error(`cookies.set was called for user cookie ${details.name}`)
}
})
const LARGE_SAFARI_COOKIE_COUNT = 150_000
describe('summarizeCookieImportError', () => {
@@ -176,19 +191,21 @@ describe('importCookiesFromFile', () => {
let tmpDir: string
let cookiesGetMock: ReturnType<typeof vi.fn>
let cookiesRemoveMock: ReturnType<typeof vi.fn>
let cookiesSetMock: ReturnType<typeof vi.fn>
let cookieWriteMock: ReturnType<typeof vi.fn>
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-test-'))
cookiesGetMock = vi.fn().mockResolvedValue([])
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
cookieWriteMock = writeCookieIdentityMock
cookieWriteMock.mockReset()
cookieWriteMock.mockResolvedValue(undefined)
sessionFromPartitionMock.mockReset()
sessionFromPartitionMock.mockReturnValue({
cookies: {
get: cookiesGetMock,
remove: cookiesRemoveMock,
set: cookiesSetMock
set: unreachableCookieSet
}
})
})
@@ -237,8 +254,8 @@ describe('importCookiesFromFile', () => {
expect(result.summary.domains).toContain('github.com')
expect(result.summary.domains).toContain('example.com')
expect(cookiesSetMock).toHaveBeenCalledTimes(2)
const firstCall = cookiesSetMock.mock.calls[0][0]
expect(cookieWriteMock).toHaveBeenCalledTimes(2)
const firstCall = cookieWriteMock.mock.calls[0][0]
expect(firstCall.name).toBe('_gh_sess')
expect(firstCall.domain).toBe('.github.com')
expect(firstCall.secure).toBe(true)
@@ -262,14 +279,18 @@ describe('importCookiesFromFile', () => {
const result = await importCookiesFromFile(filePath, 'persist:test')
expect(result.ok).toBe(true)
const hostCall = cookiesSetMock.mock.calls
const hostCall = cookieWriteMock.mock.calls
.map((c) => c[0])
.find((c) => c.name === '__Host-user_session_same_site')
// __Host- prefix requires no Domain attribute and path=/, or Chromium drops it.
expect(hostCall).not.toHaveProperty('domain')
// __Host- prefix requires no Domain attribute and path=/, or Chromium drops it. hostOnly is how
// the identity says "omit domain"; cdpSetCookieParamsFromIdentity drops it on the wire.
expect(hostCall.hostOnly).toBe(true)
expect(hostCall.path).toBe('/')
const normalCall = cookiesSetMock.mock.calls.map((c) => c[0]).find((c) => c.name === '_gh_sess')
const normalCall = cookieWriteMock.mock.calls
.map((c) => c[0])
.find((c) => c.name === '_gh_sess')
expect(normalCall.hostOnly).toBe(false)
expect(normalCall.domain).toBe('.github.com')
expect(normalCall.path).toBe('/settings')
})
@@ -363,11 +384,11 @@ describe('importCookiesFromFile', () => {
await importCookiesFromFile(filePath, 'persist:test')
expect(cookiesSetMock.mock.calls[0][0].sameSite).toBe('no_restriction')
expect(cookiesSetMock.mock.calls[1][0].sameSite).toBe('lax')
expect(cookiesSetMock.mock.calls[2][0].sameSite).toBe('strict')
expect(cookiesSetMock.mock.calls[3][0].sameSite).toBe('unspecified')
expect(cookiesSetMock.mock.calls[4][0].sameSite).toBe('unspecified')
expect(cookieWriteMock.mock.calls[0][0].sameSite).toBe('no_restriction')
expect(cookieWriteMock.mock.calls[1][0].sameSite).toBe('lax')
expect(cookieWriteMock.mock.calls[2][0].sameSite).toBe('strict')
expect(cookieWriteMock.mock.calls[3][0].sameSite).toBe('unspecified')
expect(cookieWriteMock.mock.calls[4][0].sameSite).toBe('unspecified')
})
it('derives correct URL from domain and secure flag', async () => {
@@ -379,13 +400,13 @@ describe('importCookiesFromFile', () => {
await importCookiesFromFile(filePath, 'persist:test')
expect(cookiesSetMock.mock.calls[0][0].url).toBe('https://secure.com/')
expect(cookiesSetMock.mock.calls[1][0].url).toBe('http://insecure.com/')
expect(cookiesSetMock.mock.calls[2][0].url).toBe('http://nodot.com/')
expect(cookieWriteMock.mock.calls[0][0].url).toBe('https://secure.com/')
expect(cookieWriteMock.mock.calls[1][0].url).toBe('http://insecure.com/')
expect(cookieWriteMock.mock.calls[2][0].url).toBe('http://nodot.com/')
})
it('rolls back replacement when a cookie fails to set', async () => {
cookiesSetMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed'))
cookieWriteMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed'))
const filePath = writeCookieFile([
{ domain: '.a.com', name: 'ok', value: '1' },
@@ -400,14 +421,16 @@ describe('importCookiesFromFile', () => {
describe('importCookiesFromBrowser Safari', () => {
let tmpDir: string
let cookiesSetMock: ReturnType<typeof vi.fn>
let cookieWriteMock: ReturnType<typeof vi.fn>
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-safari-cookie-test-'))
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
cookieWriteMock = writeCookieIdentityMock
cookieWriteMock.mockReset()
cookieWriteMock.mockResolvedValue(undefined)
sessionFromPartitionMock.mockReset()
sessionFromPartitionMock.mockReturnValue({
cookies: { set: cookiesSetMock }
cookies: { set: unreachableCookieSet }
})
})
@@ -429,13 +452,13 @@ describe('importCookiesFromBrowser Safari', () => {
const result = await importCookiesFromBrowser(browser, 'persist:test')
expect(result).toEqual({ ok: false, reason: 'All Safari cookies are expired.' })
expect(cookiesSetMock).not.toHaveBeenCalled()
expect(cookieWriteMock).not.toHaveBeenCalled()
})
})
describe('importCookiesFromBrowser Chromium', () => {
let tmpDir: string
let cookiesSetMock: ReturnType<typeof vi.fn>
let cookieWriteMock: ReturnType<typeof vi.fn>
let cookiesRemoveMock: ReturnType<typeof vi.fn>
let cookiesFlushStoreMock: ReturnType<typeof vi.fn>
let clearDataMock: ReturnType<typeof vi.fn>
@@ -443,7 +466,9 @@ describe('importCookiesFromBrowser Chromium', () => {
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-chromium-cookie-test-'))
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
cookieWriteMock = writeCookieIdentityMock
cookieWriteMock.mockReset()
cookieWriteMock.mockResolvedValue(undefined)
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
cookiesFlushStoreMock = vi.fn().mockResolvedValue(undefined)
clearDataMock = vi.fn().mockResolvedValue(undefined)
@@ -461,19 +486,19 @@ describe('importCookiesFromBrowser Chromium', () => {
sessionFromPartitionMock.mockReturnValue({
cookies: {
get: vi.fn().mockResolvedValue([]),
set: cookiesSetMock,
set: unreachableCookieSet,
remove: cookiesRemoveMock,
flushStore: cookiesFlushStoreMock
},
clearData: clearDataMock,
setUserAgent: setUserAgentMock
setUserAgent: setUserAgentMock,
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
})
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
it('imports from a live Chromium source DB into a Network/Cookies target profile', async () => {
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
@@ -507,7 +532,7 @@ describe('importCookiesFromBrowser Chromium', () => {
)
expect(result.ok).toBe(true)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect(cookieWriteMock).toHaveBeenCalledWith(
expect.objectContaining({
domain: '.example.com',
name: 'sid',
@@ -568,7 +593,7 @@ describe('importCookiesFromBrowser Chromium', () => {
expect.any(Array),
expect.any(Object)
)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect(cookieWriteMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'sid', value: 'encrypted-value' })
)
} finally {
@@ -623,7 +648,7 @@ describe('importCookiesFromBrowser Chromium', () => {
)
expect(result.ok).toBe(true)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect(cookieWriteMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'sid', value: 'source-value' })
)
// The partial staging file is still discarded, so no stale DB replays on cold start.
@@ -653,7 +678,7 @@ describe('importCookiesFromBrowser Chromium', () => {
)
expect(result.ok).toBe(true)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect(cookieWriteMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'sid', value: 'source-value' })
)
// The summary counts importable cookies, not staged rows.
@@ -675,7 +700,7 @@ describe('importCookiesFromBrowser Chromium', () => {
mkdirSync(dirname(targetCookiesPath), { recursive: true })
writeFileSync(targetCookiesPath, 'not a sqlite database')
// Forces the restart fallback to be the only way these cookies could ever land.
cookiesSetMock.mockRejectedValue(new Error('cookie rejected'))
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
try {
@@ -717,7 +742,7 @@ describe('importCookiesFromBrowser Chromium', () => {
targetDb.close()
// Why: without a memory failure, memoryFailed === 0 would suppress registration on its own and
// the assertion below would pass even if the insert failure never disabled staging.
cookiesSetMock.mockRejectedValue(new Error('cookie rejected'))
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
try {
@@ -727,7 +752,7 @@ describe('importCookiesFromBrowser Chromium', () => {
)
expect(result.ok).toBe(true)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect(cookieWriteMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'sid', value: 'source-value' })
)
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
@@ -773,7 +798,7 @@ describe('importCookiesFromBrowser Chromium', () => {
{ name: 'sid', value: 'source-value' }
]).close()
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
cookiesSetMock.mockRejectedValue(new Error('cookie rejected'))
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
try {
+221 -175
View File
@@ -84,8 +84,26 @@ import {
type CookieImportMode,
type ReplacedImportedDomainCookies
} from './browser-cookie-import-policy'
import { removeTransplantableCookies, withCookieClearLock } from './browser-cookie-import-clear'
import {
removeTransplantableCookies,
withCookieClearLock,
type CookieClearStore,
type CookieImportWriteStore
} from './browser-cookie-import-clear'
import { openCookieClearStore } from './browser-cookie-clear-store'
import {
readChromiumRowPartition,
readFirefoxRowPartition,
readJsonCookiePartition,
type SourcePartitionRead
} from './browser-cookie-source-partition'
import {
emptyImportWritePhase,
writeImportedCookies,
type ImportedCookieFields,
type ImportWritePhase,
type SourceCookieToWrite
} from './browser-cookie-import-write'
import {
createChromiumCookieSnapshot,
type ChromiumCookieSnapshot
@@ -448,18 +466,14 @@ type RawCookieEntry = {
httpOnly?: unknown
sameSite?: unknown
expirationDate?: unknown
partitionKey?: unknown
}
type ValidatedCookie = {
url: string
name: string
value: string
domain: string
path: string
secure: boolean
httpOnly: boolean
// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to
// state what it read. An optional field would let a new source silently default to unpartitioned.
type ValidatedCookie = ImportedCookieFields & {
sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict'
expirationDate: number | undefined
partition: SourcePartitionRead
}
// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering.
@@ -509,7 +523,7 @@ function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'la
return 'unspecified'
}
// Why: cookies.set() needs a url to scope the cookie; derive it from domain + secure flag.
// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag.
function deriveUrl(domain: string, secure: boolean): string | null {
const normalizedDomain = normalizeCookieDomain(domain)
if (!normalizedDomain) {
@@ -556,16 +570,40 @@ function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null {
secure,
httpOnly: raw.httpOnly === true || raw.httpOnly === 1,
sameSite: normalizeSameSite(raw.sameSite),
expirationDate
expirationDate,
partition: readJsonCookiePartition(raw.partitionKey)
}
}
// Why (STA-4300): the import writes get a store with no `set` on it and no Session behind it, so
// the partition-dropping write is not merely unused here — it cannot be reached.
type CookieImportSessionStore = CookieClearStore & CookieImportWriteStore & { dispose: () => void }
type CookieImportTarget = {
partition: string
openWriteStore: () => CookieImportSessionStore
}
type CookieImportOptions = {
canReportPartitionSkippedCookies?: boolean
}
function cookieImportTarget(targetPartition: string): CookieImportTarget {
const targetSession = session.fromPartition(targetPartition)
return {
partition: targetPartition,
openWriteStore: () => openCookieClearStore(targetSession)
}
}
async function importValidatedCookies(
cookies: ValidatedCookie[],
totalInput: number,
targetPartition: string,
mode: CookieImportMode
target: CookieImportTarget,
mode: CookieImportMode,
options: CookieImportOptions = {}
): Promise<BrowserCookieImportResult> {
const targetPartition = target.partition
const importDomainCache = new Map<string, boolean>()
const validDomainCookies = cookies.filter((cookie) => {
let valid = importDomainCache.get(cookie.domain)
@@ -590,128 +628,94 @@ async function importValidatedCookies(
diag(
`importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"`
)
const targetSession = session.fromPartition(targetPartition)
let importedCount = 0
// Why: an older remote client cannot surface this skip, so fail before opening the target jar.
if (
options.canReportPartitionSkippedCookies === false &&
importableCookies.some((cookie) => cookie.partition.status === 'unreadable')
) {
return {
ok: false,
reason:
'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.'
}
}
let skipped = totalInput - importableCookies.length
const domainSet = new Set<string>()
let replaced: ReplacedImportedDomainCookies | null = null
// Why (STA-4097): the rollback below has to put back cookies this import already deleted, and
// only CDP identities carry partitionKey — rebuilding them with cookies.set drops it silently.
const cookieClearStore =
mode === 'replace-imported-domains' && importableCookies.length > 0
? openCookieClearStore(targetSession)
: null
let phase: ImportWritePhase = emptyImportWritePhase()
// Why (STA-4097/STA-4300): both the rollback and the import writes need CDP identities — only
// they carry partitionKey. cookies.set drops it silently, on the success path as well.
const cookieClearStore = importableCookies.length > 0 ? target.openWriteStore() : null
try {
if (cookieClearStore) {
try {
replaced = await replaceCookiesForImportedDomains(
cookieClearStore,
importableCookies.map((cookie) => cookie.domain)
)
diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`)
} catch (err) {
diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`)
if (cookieClearStore) {
let replaced: ReplacedImportedDomainCookies | null = null
try {
if (mode === 'replace-imported-domains') {
try {
// Why: a skipped cookie must not erase the existing session it never replaces.
const replacementDomains = importableCookies
.filter((cookie) => cookie.partition.status !== 'unreadable')
.map((cookie) => cookie.domain)
replaced = await replaceCookiesForImportedDomains(cookieClearStore, replacementDomains)
diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`)
} catch (err) {
diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`)
return {
ok: false,
reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.')
}
}
}
// Why: Chromium rejects any non-printable-ASCII byte in a cookie value; strip as a safety net.
const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '')
phase = await writeImportedCookies(
cookieClearStore,
importableCookies.map((cookie) => ({ ...cookie, value: stripNonPrintable(cookie.value) })),
{ stopOnFailure: replaced !== null, log: diag }
)
skipped += phase.writeRejected + phase.partitionSkipped
if (phase.failure && replaced) {
const rollbackFailures: unknown[] = []
for (const cookie of phase.importedKeys.toReversed()) {
try {
await cookieClearStore.remove(cookie.url, cookie.name)
} catch (err) {
rollbackFailures.push(err)
}
}
// Why: restoreClearIdentities attaches the debugger before it iterates, so an empty
// restore set would spin up a hidden BrowserWindow to put nothing back.
if (replaced.identities.length > 0) {
try {
await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed())
} catch (err) {
rollbackFailures.push(err)
}
}
if (rollbackFailures.length > 0) {
diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`)
}
return {
ok: false,
reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.')
reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.')
}
}
} finally {
cookieClearStore.dispose()
}
// Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net.
const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '')
const importedCookieKeys: { url: string; name: string }[] = []
let setFailure: unknown = null
for (const cookie of importableCookies) {
try {
// Why: Chromium rejects __Host- cookies unless they omit domain and use path=/.
const isHostPrefixed = cookie.name.startsWith('__Host-')
const path = isHostPrefixed ? '/' : cookie.path
await targetSession.cookies.set({
url: cookie.url,
name: cookie.name,
value: stripNonPrintable(cookie.value),
...(isHostPrefixed ? {} : { domain: cookie.domain }),
path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
expirationDate: cookie.expirationDate
})
const removalUrl = new URL(cookie.url)
removalUrl.pathname = path.startsWith('/') ? path : '/'
importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name })
importedCount++
// Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data.
const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain
domainSet.add(cleanDomain)
} catch (err) {
skipped++
setFailure = err
if (skipped <= 5) {
// Find the exact offending character position and code
const val = cookie.value
let badInfo = 'none found'
for (let i = 0; i < val.length; i++) {
const code = val.charCodeAt(i)
if (code < 0x20 || code > 0x7e) {
badInfo = `pos=${i} char=U+${code.toString(16).padStart(4, '0')}`
break
}
}
diag(
` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}`
)
}
if (replaced) {
break
}
}
}
// Why: replaced is only ever set alongside cookieClearStore, which owns the CDP restore.
if (setFailure && replaced && cookieClearStore) {
const rollbackFailures: unknown[] = []
for (const cookie of importedCookieKeys.toReversed()) {
try {
await targetSession.cookies.remove(cookie.url, cookie.name)
} catch (err) {
rollbackFailures.push(err)
}
}
// Why: restoreClearIdentities attaches the debugger before it iterates, so an empty
// restore set would spin up a hidden BrowserWindow to put nothing back.
if (replaced.identities.length > 0) {
try {
await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed())
} catch (err) {
rollbackFailures.push(err)
}
}
if (rollbackFailures.length > 0) {
diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`)
}
return {
ok: false,
reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.')
}
}
} finally {
cookieClearStore?.dispose()
}
diag(
`importValidatedCookies result: imported=${importedCount} skipped=${skipped} domains=${domainSet.size}`
`importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${phase.partitionSkipped} domains=${phase.domains.size}`
)
const summary: BrowserCookieImportSummary = {
totalCookies: totalInput,
importedCookies: importedCount,
importedCookies: phase.importedCount,
skippedCookies: skipped,
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
domains: [...domainSet].sort()
...(phase.partitionSkipped > 0 ? { partitionSkippedCookies: phase.partitionSkipped } : {}),
domains: [...phase.domains].sort()
}
return { ok: true, profileId: '', summary }
@@ -792,7 +796,7 @@ export async function importCookiesFromFile(
return importValidatedCookies(
validated,
parsed.length,
targetPartition,
cookieImportTarget(targetPartition),
'replace-imported-domains'
)
}
@@ -1264,7 +1268,10 @@ function decodeSafariCookie(buf: Buffer): ValidatedCookie | null {
secure,
httpOnly,
sameSite: 'unspecified',
expirationDate
expirationDate,
// Why: Cookies.binarycookies has no partition field — Safari's format predates CHIPS, so every
// decoded cookie is genuinely unpartitioned rather than missing an identity.
partition: { status: 'unpartitioned' }
}
}
@@ -1288,7 +1295,8 @@ function readCString(buf: Buffer, offset: number, end: number): string | null {
async function importCookiesFromFirefox(
browser: DetectedBrowser,
targetPartition: string
targetPartition: string,
options: CookieImportOptions
): Promise<BrowserCookieImportResult> {
diag(`importCookiesFromFirefox: partition="${targetPartition}"`)
@@ -1317,7 +1325,7 @@ async function importCookiesFromFirefox(
try {
const db = new DatabaseSync(tmpCookiesPath, { readOnly: true })
type FirefoxRow = {
type FirefoxRow = Record<string, unknown> & {
name: string
value: string
host: string
@@ -1326,10 +1334,21 @@ async function importCookiesFromFirefox(
isSecure: number
isHttpOnly: number
sameSite: number
isPartitionedAttributeSet?: number
}
// Why: selecting a column an older moz_cookies schema lacks fails the whole import. A schema
// without the server-declared partition flag predates that cookie identity.
const firefoxColumns = new Set(
(db.prepare('PRAGMA table_info(moz_cookies)').all() as { name: string }[]).map(
(column) => column.name
)
)
const partitionColumn = firefoxColumns.has('isPartitionedAttributeSet')
? ', isPartitionedAttributeSet'
: ''
const rows = db
.prepare(
'SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite FROM moz_cookies'
`SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies`
)
.all() as FirefoxRow[]
db.close()
@@ -1366,7 +1385,8 @@ async function importCookiesFromFirefox(
secure,
httpOnly: row.isHttpOnly === 1,
sameSite: firefoxSameSite(row.sameSite),
expirationDate: row.expiry > 0 ? row.expiry : undefined
expirationDate: row.expiry > 0 ? row.expiry : undefined,
partition: readFirefoxRowPartition(row, firefoxColumns)
})
}
@@ -1379,8 +1399,9 @@ async function importCookiesFromFirefox(
return importValidatedCookies(
validated,
rows.length,
targetPartition,
'replace-imported-domains'
cookieImportTarget(targetPartition),
'replace-imported-domains',
options
)
} catch (err) {
rmSync(tmpDir, { recursive: true, force: true })
@@ -1438,7 +1459,7 @@ async function importCookiesFromSafari(
return importValidatedCookies(
valid,
cookies.length,
targetPartition,
cookieImportTarget(targetPartition),
'replace-imported-domains'
)
} catch (err) {
@@ -1453,7 +1474,8 @@ async function importCookiesFromSafari(
export async function importCookiesFromBrowser(
browser: DetectedBrowser,
targetPartition: string
targetPartition: string,
options: CookieImportOptions = {}
): Promise<BrowserCookieImportResult> {
diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`)
if (!existsSync(browser.cookiesPath)) {
@@ -1462,7 +1484,7 @@ export async function importCookiesFromBrowser(
}
if (browser.family === 'firefox') {
return importCookiesFromFirefox(browser, targetPartition)
return importCookiesFromFirefox(browser, targetPartition, options)
}
if (browser.family === 'safari') {
return importCookiesFromSafari(browser, targetPartition)
@@ -1474,8 +1496,15 @@ export async function importCookiesFromBrowser(
const targetSession = session.fromPartition(targetPartition)
await targetSession.cookies.flushStore()
// Why (STA-4300): ask the Session where its own storage lives instead of rebuilding the path from
// the caller's partition string. String surgery on a caller-supplied name is what let a value like
// "persist:../.." resolve a Cookies DB outside the Partitions directory and stage a replacement
// over it; it also drifts whenever Chromium changes how a partition name maps to a directory.
const partitionDir = targetSession.getStoragePath()
if (!partitionDir) {
return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' }
}
const partitionName = targetPartition.replace('persist:', '')
const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName)
let liveCookiesPath = resolveChromiumCookiesPath(partitionDir)
// Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles.
@@ -1591,6 +1620,13 @@ export async function importCookiesFromBrowser(
}
}
// Why (STA-4300): the partition columns drift across Chromium versions, so read the source
// schema rather than assuming a row's missing column means "unpartitioned".
const sourceColumns = new Set(
(sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map(
(column) => column.name
)
)
const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record<
string,
unknown
@@ -1632,20 +1668,15 @@ export async function importCookiesFromBrowser(
let skipped = 0
let integritySkipped = 0
let nonTransplantableSkipped = 0
let partitionSkipped = 0
let memoryLoaded = 0
let memoryFailed = 0
const domainSet = new Set<string>()
type DecryptedCookie = {
type DecryptedCookie = Omit<ImportedCookieFields, 'url'> & {
decryptedValue: Buffer
value: string
domain: string
name: string
path: string
secure: boolean
httpOnly: boolean
sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict'
expirationDate: number | undefined
partition: SourcePartitionRead
}
const decryptedCookies: DecryptedCookie[] = []
@@ -1721,14 +1752,12 @@ export async function importCookiesFromBrowser(
continue
}
const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain
domainSet.add(cleanDomain)
const path = sourceRow.path as string
const secure = sourceRow.is_secure === 1n
const httpOnly = sourceRow.is_httponly === 1n
const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0))
const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint)
const partition = readChromiumRowPartition(sourceRow, sourceColumns)
// Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x000xFF without lossy replacement.
const value = decryptedValue.toString('latin1')
@@ -1741,9 +1770,18 @@ export async function importCookiesFromBrowser(
secure,
httpOnly,
sameSite,
expirationDate: expiresUtc > 0 ? expiresUtc : undefined
expirationDate: expiresUtc > 0 ? expiresUtc : undefined,
partition
})
if (partition.status === 'unreadable') {
partitionSkipped++
skipped++
continue
}
const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain
domainSet.add(cleanDomain)
if (insertStmt && targetColumnInfo) {
try {
const params = buildChromiumCookieInsertParams(
@@ -1765,7 +1803,18 @@ export async function importCookiesFromBrowser(
)
const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped
if (decryptedCookies.length === 0) {
// Why: an older remote client ignores the new counter and would present this loss as success.
if (partitionSkipped > 0 && options.canReportPartitionSkippedCookies === false) {
closeStagingDb()
discardStagingFile()
return {
ok: false,
reason:
'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.'
}
}
if (imported === 0) {
closeStagingDb()
discardStagingFile()
return {
@@ -1776,6 +1825,7 @@ export async function importCookiesFromBrowser(
importedCookies: 0,
skippedCookies: skipped + integritySkipped + nonTransplantableSkipped,
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}),
domains: []
}
}
@@ -1796,6 +1846,8 @@ 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.
// Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak
// the same CDP identities — cookies.set() cannot express the partition either one reads.
const cookieClearStore = openCookieClearStore(targetSession)
try {
await withCookieClearLock(targetSession, () =>
@@ -1807,42 +1859,35 @@ export async function importCookiesFromBrowser(
cookieClearStore.restoreClearIdentities(identities)
})
)
diag(
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
)
const writable: SourceCookieToWrite[] = []
for (const cookie of decryptedCookies) {
const url = deriveUrl(cookie.domain, cookie.secure)
if (!url) {
memoryFailed++
continue
}
writable.push({ ...cookie, url })
}
// Why: a rejected cookie here falls back to the staged cold-start replay rather than
// unwinding the import, so one failure must not stop the rest from loading.
const phase = await writeImportedCookies(cookieClearStore, writable, {
stopOnFailure: false,
log: diag
})
memoryLoaded = phase.importedCount
memoryFailed += phase.writeRejected
} finally {
cookieClearStore.dispose()
}
diag(
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
` memory load: ${memoryLoaded} OK, ${memoryFailed} failed, ${partitionSkipped} partition-unreadable`
)
// Why: load into memory via cookies.set() so imported cookies work without a restart.
for (const cookie of decryptedCookies) {
const url = deriveUrl(cookie.domain, cookie.secure)
if (!url) {
memoryFailed++
continue
}
try {
// Why: Chromium rejects __Host- cookies unless they omit domain and use path=/.
const isHostPrefixed = cookie.name.startsWith('__Host-')
await targetSession.cookies.set({
url,
name: cookie.name,
value: cookie.value,
...(isHostPrefixed ? {} : { domain: cookie.domain }),
path: isHostPrefixed ? '/' : cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
expirationDate: cookie.expirationDate
})
memoryLoaded++
} catch {
memoryFailed++
}
}
diag(` memory load: ${memoryLoaded} OK, ${memoryFailed} failed`)
let warning: BrowserCookieImportSummary['warning']
if (memoryFailed > 0 && stagingAvailable) {
// Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation.
@@ -1879,6 +1924,7 @@ export async function importCookiesFromBrowser(
importedCookies: imported,
skippedCookies: skipped + integritySkipped + nonTransplantableSkipped,
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}),
domains: [...domainSet].sort(),
...(warning ? { warning } : {})
}
@@ -0,0 +1,177 @@
import { describe, expect, it } from 'vitest'
import {
readChromiumRowPartition,
readFirefoxRowPartition,
readJsonCookiePartition
} from './browser-cookie-source-partition'
const MODERN_COLUMNS = new Set([
'host_key',
'name',
'top_frame_site_key',
'has_cross_site_ancestor'
])
describe('readChromiumRowPartition', () => {
it('reads a partitioned row as both halves of the partition key', () => {
expect(
readChromiumRowPartition(
{ top_frame_site_key: 'https://top.example', has_cross_site_ancestor: 1n },
MODERN_COLUMNS
)
).toEqual({
status: 'partitioned',
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
})
})
it('carries a false cross-site-ancestor rather than defaulting it to true', () => {
expect(
readChromiumRowPartition(
{ top_frame_site_key: 'https://top.example', has_cross_site_ancestor: 0n },
MODERN_COLUMNS
)
).toEqual({
status: 'partitioned',
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: false }
})
})
it('reads an empty partition site as unpartitioned', () => {
expect(
readChromiumRowPartition(
{ top_frame_site_key: '', has_cross_site_ancestor: 0n },
MODERN_COLUMNS
)
).toEqual({ status: 'unpartitioned' })
})
// Why: a schema predating cookie partitioning genuinely has no partitioned rows, so importing
// every one of its cookies unpartitioned is faithful rather than lossy.
it('reads a schema without the partition column as unpartitioned', () => {
expect(readChromiumRowPartition({ name: 'sid' }, new Set(['host_key', 'name']))).toEqual({
status: 'unpartitioned'
})
})
// Why (STA-4300): the ancestor bit selects which partition the cookie lands in. Guessing it files
// the cookie under a partition the site never reads — indistinguishable from losing it.
it('refuses a partitioned row whose schema has no cross-site-ancestor column', () => {
const result = readChromiumRowPartition(
{ top_frame_site_key: 'https://top.example' },
new Set(['host_key', 'name', 'top_frame_site_key'])
)
expect(result.status).toBe('unreadable')
expect(result).toHaveProperty('reason', expect.stringContaining('cross-site-ancestor'))
})
it('refuses a partitioned row whose ancestor flag is not an integer', () => {
expect(
readChromiumRowPartition(
{ top_frame_site_key: 'https://top.example', has_cross_site_ancestor: null },
MODERN_COLUMNS
).status
).toBe('unreadable')
})
it('refuses a partition site column that is not text', () => {
expect(
readChromiumRowPartition(
{ top_frame_site_key: 42, has_cross_site_ancestor: 1n },
MODERN_COLUMNS
).status
).toBe('unreadable')
})
})
describe('readJsonCookiePartition', () => {
// Why: every mainstream exporter omits the field for ordinary cookies, so absence has to mean
// unpartitioned or whole exports would be rejected.
it('reads an absent partitionKey as unpartitioned', () => {
expect(readJsonCookiePartition(undefined)).toEqual({ status: 'unpartitioned' })
expect(readJsonCookiePartition(null)).toEqual({ status: 'unpartitioned' })
})
it('reads a complete partitionKey object', () => {
expect(
readJsonCookiePartition({ topLevelSite: 'https://top.example', hasCrossSiteAncestor: true })
).toEqual({
status: 'partitioned',
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
})
})
// Why: exporters that emit only topLevelSite carry no ancestor bit. Skipping is reported; a
// guessed bit would be a silent misfile.
it('refuses a partitionKey missing the cross-site-ancestor bit', () => {
expect(readJsonCookiePartition({ topLevelSite: 'https://top.example' }).status).toBe(
'unreadable'
)
})
it('refuses the legacy string partitionKey form', () => {
expect(readJsonCookiePartition('https://top.example').status).toBe('unreadable')
})
it('refuses a partitionKey with a non-boolean ancestor bit', () => {
expect(
readJsonCookiePartition({ topLevelSite: 'https://top.example', hasCrossSiteAncestor: 'yes' })
.status
).toBe('unreadable')
})
it('refuses an array or empty-site partitionKey', () => {
expect(readJsonCookiePartition([]).status).toBe('unreadable')
expect(readJsonCookiePartition({ topLevelSite: '', hasCrossSiteAncestor: true }).status).toBe(
'unreadable'
)
})
})
describe('readFirefoxRowPartition', () => {
// Why: originAttributes partitionKey components describe Firefox storage isolation, not whether
// the server declared the cookie Partitioned.
it('keeps a dFPI row unpartitioned even with ancestor context', () => {
expect(
readFirefoxRowPartition(
{
originAttributes: '^partitionKey=(https,example.com,f)',
isPartitionedAttributeSet: 0n
},
new Set(['originAttributes', 'isPartitionedAttributeSet'])
)
).toEqual({ status: 'unpartitioned' })
})
it('refuses a server-declared partitioned cookie', () => {
const result = readFirefoxRowPartition(
{
originAttributes: '^partitionKey=(https,example.com)',
isPartitionedAttributeSet: 1n
},
new Set(['originAttributes', 'isPartitionedAttributeSet'])
)
expect(result.status).toBe('unreadable')
expect(result).toHaveProperty('reason', expect.stringContaining('cross-site-ancestor'))
})
it('reads a schema without the partitioned-attribute column as unpartitioned', () => {
expect(
readFirefoxRowPartition(
{ originAttributes: '^partitionKey=(https,example.com,f)' },
new Set(['originAttributes'])
)
).toEqual({ status: 'unpartitioned' })
})
it('refuses an invalid partitioned-attribute flag', () => {
expect(
readFirefoxRowPartition(
{ isPartitionedAttributeSet: null },
new Set(['isPartitionedAttributeSet'])
).status
).toBe('unreadable')
})
})
@@ -0,0 +1,125 @@
import type { CookieClearPartitionKey } from './browser-cookie-import-clear'
// Why (STA-4300): a partition identity that cannot be read faithfully must skip the cookie, never
// downgrade it. An unpartitioned write of a partitioned cookie looks like a success and leaves the
// site unable to see its own session — the failure mode that produced STA-4013/4061/4090/4170.
export type SourcePartitionRead =
| { status: 'unpartitioned' }
| { status: 'partitioned'; partitionKey: CookieClearPartitionKey }
| { status: 'unreadable'; reason: string }
const CHROMIUM_PARTITION_SITE_COLUMN = 'top_frame_site_key'
const CHROMIUM_CROSS_SITE_ANCESTOR_COLUMN = 'has_cross_site_ancestor'
const UNPARTITIONED: SourcePartitionRead = { status: 'unpartitioned' }
function readSqliteFlag(raw: unknown): boolean | null {
if (typeof raw === 'boolean') {
return raw
}
if (typeof raw === 'bigint') {
return raw !== 0n
}
if (typeof raw === 'number' && Number.isFinite(raw)) {
return raw !== 0
}
return null
}
/**
* Reads a Chromium cookie row's partition identity.
*
* Chromium stores the partition as `top_frame_site_key` (empty string when unpartitioned) plus
* `has_cross_site_ancestor`, which older schemas predate. Both halves are required: a partition key
* written with the wrong ancestor bit files the cookie under a partition the site never reads, which
* is indistinguishable from losing it.
*/
export function readChromiumRowPartition(
sourceRow: Record<string, unknown>,
sourceColumns: ReadonlySet<string>
): SourcePartitionRead {
// Why: a schema without the column predates cookie partitioning, so every row is genuinely
// unpartitioned — that is a faithful read, not a missing one.
if (!sourceColumns.has(CHROMIUM_PARTITION_SITE_COLUMN)) {
return UNPARTITIONED
}
const rawSite = sourceRow[CHROMIUM_PARTITION_SITE_COLUMN]
if (rawSite === null || rawSite === undefined || rawSite === '') {
return UNPARTITIONED
}
if (typeof rawSite !== 'string') {
return { status: 'unreadable', reason: 'partition site column was not text' }
}
if (!sourceColumns.has(CHROMIUM_CROSS_SITE_ANCESTOR_COLUMN)) {
return {
status: 'unreadable',
reason: 'source schema has no cross-site-ancestor column for a partitioned cookie'
}
}
const hasCrossSiteAncestor = readSqliteFlag(sourceRow[CHROMIUM_CROSS_SITE_ANCESTOR_COLUMN])
if (hasCrossSiteAncestor === null) {
return { status: 'unreadable', reason: 'cross-site-ancestor column was not an integer flag' }
}
return { status: 'partitioned', partitionKey: { topLevelSite: rawSite, hasCrossSiteAncestor } }
}
const FIREFOX_PARTITIONED_ATTRIBUTE_COLUMN = 'isPartitionedAttributeSet'
/**
* Reads whether Firefox recorded the server-declared `Partitioned` attribute.
*
* `originAttributes.partitionKey` is Firefox storage isolation and may include port or ancestor
* context. The separate `isPartitionedAttributeSet` column is the server-declared CHIPS signal.
*/
export function readFirefoxRowPartition(
sourceRow: Record<string, unknown>,
sourceColumns: ReadonlySet<string>
): SourcePartitionRead {
if (!sourceColumns.has(FIREFOX_PARTITIONED_ATTRIBUTE_COLUMN)) {
return UNPARTITIONED
}
const partitionedAttribute = readSqliteFlag(sourceRow[FIREFOX_PARTITIONED_ATTRIBUTE_COLUMN])
if (partitionedAttribute === false) {
return UNPARTITIONED
}
if (partitionedAttribute === null) {
return { status: 'unreadable', reason: 'partitioned-attribute column was not an integer flag' }
}
return {
status: 'unreadable',
reason: 'Firefox partitioned-attribute cookie has no cross-site-ancestor bit to read'
}
}
/**
* Reads a JSON cookie entry's partition identity.
*
* Absent means unpartitioned — every mainstream exporter omits the field for ordinary cookies, so
* treating absence as unreadable would reject whole exports. Present-but-incomplete is unreadable:
* exporters that emit only `topLevelSite` (or the legacy CDP string form) carry no ancestor bit, and
* guessing it silently misfiles the cookie.
*/
export function readJsonCookiePartition(raw: unknown): SourcePartitionRead {
if (raw === undefined || raw === null || raw === '') {
return UNPARTITIONED
}
if (typeof raw !== 'object' || Array.isArray(raw)) {
return { status: 'unreadable', reason: 'partitionKey was not an object with both fields' }
}
const { topLevelSite, hasCrossSiteAncestor } = raw as Record<string, unknown>
if (typeof topLevelSite !== 'string' || topLevelSite.length === 0) {
return { status: 'unreadable', reason: 'partitionKey.topLevelSite was missing or not text' }
}
if (typeof hasCrossSiteAncestor !== 'boolean') {
return {
status: 'unreadable',
reason: 'partitionKey.hasCrossSiteAncestor was missing or not a boolean'
}
}
return { status: 'partitioned', partitionKey: { topLevelSite, hasCrossSiteAncestor } }
}
@@ -259,9 +259,6 @@ describe('Crashpad dump pruning', () => {
await _pruneCrashpadDumpsForTest(16)
expect((await readdir(path.join(dumpDir, 'reports'))).sort()).toEqual([
'middle.dmp',
'new.dmp'
])
expect((await readdir(path.join(dumpDir, 'reports'))).sort()).toEqual(['middle.dmp', 'new.dmp'])
})
})
+4 -1
View File
@@ -1550,6 +1550,7 @@ export class RuntimeBrowserCommands {
profileId: string
browserFamily: string
browserProfile?: string
supportsPartitionSkippedCookies?: true
}): Promise<BrowserProfileImportFromBrowserResult> {
const profile = browserSessionRegistry.getProfile(params.profileId)
if (!profile) {
@@ -1579,7 +1580,9 @@ export class RuntimeBrowserCommands {
browser = reselected
}
const result = await importCookiesFromBrowser(browser, profile.partition)
const result = await importCookiesFromBrowser(browser, profile.partition, {
canReportPartitionSkippedCookies: params.supportsPartitionSkippedCookies === true
})
if (!result.ok) {
return result
}
@@ -148,7 +148,8 @@ export const ProfileDelete = z.object({ profileId: requiredString('Missing requi
export const ProfileImportFromBrowser = z.object({
profileId: requiredString('Missing required --profile'),
browserFamily: requiredString('Missing required --browser-family'),
browserProfile: OptionalString
browserProfile: OptionalString,
supportsPartitionSkippedCookies: z.literal(true).optional()
})
export const Drag = BrowserTarget.extend({
+4 -2
View File
@@ -68,7 +68,8 @@ describe('browser RPC methods', () => {
makeRequest('browser.profileImportFromBrowser', {
profileId: 'profile-1',
browserFamily: 'chrome',
browserProfile: 'Default'
browserProfile: 'Default',
supportsPartitionSkippedCookies: true
})
)
@@ -92,7 +93,8 @@ describe('browser RPC methods', () => {
expect(runtime.browserProfileImportFromBrowser).toHaveBeenCalledWith({
profileId: 'profile-1',
browserFamily: 'chrome',
browserProfile: 'Default'
browserProfile: 'Default',
supportsPartitionSkippedCookies: true
})
})
+2 -1
View File
@@ -783,7 +783,8 @@
"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.",
"googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google."
"googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google.",
"partitionSkipped": "{{value0}} cookies were not imported because their site-partition could not be read. Sign in to those sites again in Orca."
}
}
}
@@ -69,6 +69,32 @@ describe('emitBrowserCookieImportToast', () => {
)
})
// Why (STA-4300): these cookies were skipped rather than written unpartitioned, so the success
// count alone would report a lossy import as clean.
it('warns separately about cookies skipped for an unreadable partition', () => {
emitBrowserCookieImportToast(
{ ...summary, importedCookies: 2, skippedCookies: 1, partitionSkippedCookies: 1 },
'Imported 2 cookies.',
'Local Mac'
)
expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.')
expect(warningToastMock).toHaveBeenCalledWith(
'1 cookies were not imported because their site-partition could not be read. Sign in to those sites again in Orca.',
{ duration: 12000 }
)
})
it('does not infer a partition warning from generic skipped cookies', () => {
emitBrowserCookieImportToast(
{ ...summary, importedCookies: 2, skippedCookies: 1 },
'Imported 2 cookies.',
'Local Mac'
)
expect(warningToastMock).not.toHaveBeenCalled()
})
it('does not infer a Google warning from generic skipped cookies', () => {
emitBrowserCookieImportToast(
{ ...summary, importedCookies: 2, skippedCookies: 1 },
@@ -41,6 +41,22 @@ function emitGoogleCookieImportWarning(
)
}
// Why (STA-4300): these cookies were skipped rather than downgraded to unpartitioned, so the import
// is lossy in a way the success count alone would hide.
function emitPartitionSkippedImportWarning(summary: BrowserCookieImportSummary): void {
if (!summary.partitionSkippedCookies) {
return
}
toast.warning(
translate(
'auto.lib.browser.cookie.import.toast.partitionSkipped',
'{{value0}} cookies were not imported because their site-partition could not be read. Sign in to those sites again in Orca.',
{ value0: summary.partitionSkippedCookies }
),
{ duration: 12000 }
)
}
// 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(
@@ -55,4 +71,5 @@ export function emitBrowserCookieImportToast(
toast.success(successMessage)
}
emitGoogleCookieImportWarning(summary, executionHostLabel)
emitPartitionSkippedImportWarning(summary)
}
@@ -169,6 +169,17 @@ describe('createBrowserSlice runtime guard', () => {
.getState()
.importCookiesFromBrowser('windows-profile', 'chrome', 'Default')
await vi.waitFor(() => expect(resolveImport).toBeDefined())
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'windows-2',
method: 'browser.profileImportFromBrowser',
params: {
profileId: 'windows-profile',
browserFamily: 'chrome',
browserProfile: 'Default',
supportsPartitionSkippedCookies: true
},
timeoutMs: 30_000
})
await store.getState().setBrowserSessionHostId('runtime:linux-3')
const callsBeforeCompletion = runtimeEnvironmentCall.mock.calls.length
resolveImport?.({
+1 -1
View File
@@ -2201,7 +2201,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
const result = await callRuntimeRpc<BrowserProfileImportFromBrowserResult>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileImportFromBrowser',
{ profileId, browserFamily, browserProfile },
{ profileId, browserFamily, browserProfile, supportsPartitionSkippedCookies: true },
{ timeoutMs: 30_000 }
)
if (result.ok) {
+3
View File
@@ -142,6 +142,9 @@ export type BrowserCookieImportSummary = {
importedCookies: number
skippedCookies: number
googleCookiesSkipped?: number
// Why (STA-4300): cookies whose source partition identity could not be read faithfully are
// skipped rather than written unpartitioned, and a skip is only honest if it is reported.
partitionSkippedCookies?: number
domains: string[]
warning?: {
code: 'restart-fallback-unavailable'