Skip to content

Commit e4070cb

Browse files
fix(desktop): keep built-in browser logins across app restarts
1 parent 6d77ae1 commit e4070cb

5 files changed

Lines changed: 195 additions & 3 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { OnHeadersReceivedListenerDetails, Session } from 'electron'
5+
import { describe, expect, it, vi } from 'vitest'
6+
import {
7+
keepSessionCookiesAcrossRestarts,
8+
SESSION_COOKIE_LIFETIME_SECONDS,
9+
withSessionCookieLifetime,
10+
withSessionCookieMaxAge,
11+
} from '@/main/browser-agent/session-cookies'
12+
13+
vi.mock('electron', () => import('@/test/electron-mock'))
14+
15+
const NOW = 1_800_000_000
16+
const MAX_AGE = `Max-Age=${SESSION_COOKIE_LIFETIME_SECONDS}`
17+
18+
describe('withSessionCookieLifetime', () => {
19+
it('gives an expiry-less cookie the bounded lifetime', () => {
20+
expect(withSessionCookieLifetime({ url: 'https://example.com/', name: 'a' }, NOW)).toEqual({
21+
url: 'https://example.com/',
22+
name: 'a',
23+
expirationDate: NOW + SESSION_COOKIE_LIFETIME_SECONDS,
24+
})
25+
})
26+
27+
it('leaves a cookie that already expires untouched', () => {
28+
const cookie = { url: 'https://example.com/', name: 'a', expirationDate: NOW + 60 }
29+
expect(withSessionCookieLifetime(cookie, NOW)).toBe(cookie)
30+
})
31+
})
32+
33+
describe('withSessionCookieMaxAge', () => {
34+
it('adds Max-Age to a session cookie', () => {
35+
expect(withSessionCookieMaxAge('sid=abc; Path=/; Secure; HttpOnly')).toBe(
36+
`sid=abc; Path=/; Secure; HttpOnly; ${MAX_AGE}`
37+
)
38+
expect(withSessionCookieMaxAge('sid=abc')).toBe(`sid=abc; ${MAX_AGE}`)
39+
})
40+
41+
it('leaves cookies with an expiry or a deletion untouched', () => {
42+
for (const value of [
43+
'sid=abc; Expires=Wed, 21 Oct 2030 07:28:00 GMT',
44+
'sid=abc; max-age=60',
45+
'sid=; Path=/; Max-Age=0',
46+
'sid=abc;EXPIRES=Thu, 01 Jan 1970 00:00:00 GMT',
47+
]) {
48+
expect(withSessionCookieMaxAge(value)).toBe(value)
49+
}
50+
})
51+
52+
it('reads attributes only, never the cookie value', () => {
53+
expect(withSessionCookieMaxAge('max-age=1')).toBe(`max-age=1; ${MAX_AGE}`)
54+
})
55+
})
56+
57+
describe('keepSessionCookiesAcrossRestarts', () => {
58+
type Listener = (
59+
details: Pick<OnHeadersReceivedListenerDetails, 'responseHeaders'>,
60+
callback: (response: { responseHeaders?: Record<string, string[]> }) => void
61+
) => void
62+
63+
function install(): Listener {
64+
let listener: Listener | undefined
65+
const ses = {
66+
webRequest: {
67+
onHeadersReceived: vi.fn((handler: Listener) => {
68+
listener = handler
69+
}),
70+
},
71+
}
72+
keepSessionCookiesAcrossRestarts(ses as unknown as Session)
73+
if (!listener) throw new Error('listener not installed')
74+
return listener
75+
}
76+
77+
it('rewrites every Set-Cookie value and keeps the other headers', () => {
78+
const callback = vi.fn()
79+
install()(
80+
{
81+
responseHeaders: {
82+
'content-type': ['text/html'],
83+
'Set-Cookie': ['sid=abc; HttpOnly', 'keep=1; Max-Age=60'],
84+
},
85+
},
86+
callback
87+
)
88+
89+
expect(callback).toHaveBeenCalledWith({
90+
responseHeaders: {
91+
'content-type': ['text/html'],
92+
'Set-Cookie': [`sid=abc; HttpOnly; ${MAX_AGE}`, 'keep=1; Max-Age=60'],
93+
},
94+
})
95+
})
96+
97+
it('leaves responses without cookies untouched', () => {
98+
const callback = vi.fn()
99+
install()({ responseHeaders: { 'content-type': ['text/html'] } }, callback)
100+
install()({}, callback)
101+
102+
expect(callback).toHaveBeenNthCalledWith(1, {})
103+
expect(callback).toHaveBeenNthCalledWith(2, {})
104+
})
105+
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { CookiesSetDetails, Session } from 'electron'
2+
3+
/**
4+
* Session cookies in the browser partition get this lifetime instead of dying
5+
* with the process.
6+
*
7+
* Electron hard-codes Chromium's `persist_session_cookies` and
8+
* `restore_old_session_cookies` to false, so a `persist:` partition still drops
9+
* every expiry-less cookie on quit — which signs the user out of any site that
10+
* keeps its login in one, whether it was imported from Chrome or set here.
11+
* Chrome keeps them across restarts under "continue where you left off"; this
12+
* matches that, bounded. The site re-sending the cookie renews the window, so
13+
* only a login left untouched for this long lapses.
14+
*/
15+
export const SESSION_COOKIE_LIFETIME_SECONDS = 30 * 24 * 60 * 60
16+
17+
/** Gives an expiry-less cookie the bounded lifetime; cookies that already expire are unchanged. */
18+
export function withSessionCookieLifetime(
19+
cookie: CookiesSetDetails,
20+
nowSeconds: number
21+
): CookiesSetDetails {
22+
if (cookie.expirationDate !== undefined) return cookie
23+
return { ...cookie, expirationDate: nowSeconds + SESSION_COOKIE_LIFETIME_SECONDS }
24+
}
25+
26+
/**
27+
* Adds `Max-Age` to a `Set-Cookie` value that has neither `Expires` nor
28+
* `Max-Age`. Deletions carry one of the two, so they pass through untouched.
29+
*/
30+
export function withSessionCookieMaxAge(setCookie: string): string {
31+
const attributes = setCookie.split(';').slice(1)
32+
const expires = attributes.some((attribute) => {
33+
const name = attribute.split('=', 1)[0].trim().toLowerCase()
34+
return name === 'expires' || name === 'max-age'
35+
})
36+
return expires ? setCookie : `${setCookie}; Max-Age=${SESSION_COOKIE_LIFETIME_SECONDS}`
37+
}
38+
39+
/**
40+
* Makes session cookies a site sets over HTTP survive an app restart.
41+
*
42+
* The rewrite happens on the response headers, before Chromium stores the
43+
* cookie, so it is created persistent in the same order as every other cookie
44+
* write — a later deletion from the site still wins. Rewriting stored cookies
45+
* after the fact would race exactly that: login redirects set and clear
46+
* short-lived cookies milliseconds apart. Cookies set from page script
47+
* (`document.cookie`) are not covered; they cannot be `HttpOnly`, so session
48+
* logins rarely live in them.
49+
*/
50+
export function keepSessionCookiesAcrossRestarts(ses: Session): void {
51+
ses.webRequest.onHeadersReceived((details, callback) => {
52+
const headers = details.responseHeaders
53+
const key = headers && Object.keys(headers).find((name) => name.toLowerCase() === 'set-cookie')
54+
if (!headers || !key) {
55+
callback({})
56+
return
57+
}
58+
callback({ responseHeaders: { ...headers, [key]: headers[key].map(withSessionCookieMaxAge) } })
59+
})
60+
}

‎apps/desktop/src/main/browser-agent/session.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2277,6 +2277,26 @@ describe('importAgentCookies', () => {
22772277
sameSite: 'lax' as const,
22782278
})
22792279

2280+
it('gives session cookies a bounded lifetime so they survive a restart', async () => {
2281+
vi.useFakeTimers({ now: new Date('2026-09-26T00:00:00Z') })
2282+
try {
2283+
const set = vi.fn(async () => {})
2284+
const session = withCookieJar(set)
2285+
const expiring = { ...cookie('kept'), expirationDate: 1_900_000_000 }
2286+
2287+
await session.importAgentCookies([cookie('session'), expiring])
2288+
2289+
const nowSeconds = Date.parse('2026-09-26T00:00:00Z') / 1000
2290+
expect(set).toHaveBeenNthCalledWith(1, {
2291+
...cookie('session'),
2292+
expirationDate: nowSeconds + 30 * 24 * 60 * 60,
2293+
})
2294+
expect(set).toHaveBeenNthCalledWith(2, expiring)
2295+
} finally {
2296+
vi.useRealTimers()
2297+
}
2298+
})
2299+
22802300
it('counts a rejected cookie without losing the rest', async () => {
22812301
// Chromium refuses cookies whose attributes are inconsistent. That
22822302
// rejection must cost one cookie, not the whole import.

‎apps/desktop/src/main/browser-agent/session.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ import {
7272
registerAgentWebContents,
7373
} from '@/main/browser-agent/registry'
7474
import { handleBrowserRequest } from '@/main/browser-agent/request-policy'
75+
import {
76+
keepSessionCookiesAcrossRestarts,
77+
withSessionCookieLifetime,
78+
} from '@/main/browser-agent/session-cookies'
7579
import { clearHostVerdictCache } from '@/main/browser-agent/url-guard'
7680
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
7781
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
@@ -1327,17 +1331,19 @@ export async function listAgentCookieSignals(): Promise<BrowserCookieSignal[]> {
13271331
* here and is counted rather than being quietly relaxed.
13281332
*
13291333
* Failures are per-cookie: one rejected cookie must not cost the user the
1330-
* rest. Nothing about a cookie is logged.
1334+
* rest. Nothing about a cookie is logged. Session cookies get the bounded
1335+
* lifetime from {@link withSessionCookieLifetime} so they survive a restart.
13311336
*/
13321337
export async function importAgentCookies(
13331338
cookies: CookiesSetDetails[]
13341339
): Promise<{ imported: number; failed: number }> {
13351340
const jar = electronSession.fromPartition(AGENT_PARTITION).cookies
1341+
const nowSeconds = Date.now() / 1000
13361342
let imported = 0
13371343
let failed = 0
13381344
for (const cookie of cookies) {
13391345
try {
1340-
await jar.set(cookie)
1346+
await jar.set(withSessionCookieLifetime(cookie, nowSeconds))
13411347
imported += 1
13421348
} catch {
13431349
failed += 1
@@ -1594,6 +1600,7 @@ const browserPermissions: BrowserPermissionHandlers = {
15941600
function configureAgentPartition(ses: Session): void {
15951601
if (configuredPartitions.has(ses)) return
15961602
configuredPartitions.add(ses)
1603+
keepSessionCookiesAcrossRestarts(ses)
15971604
ses.setPermissionRequestHandler(browserPermissions.request)
15981605
ses.setPermissionCheckHandler(browserPermissions.check)
15991606
ses.webRequest.onBeforeRequest((details, callback) => {

‎apps/desktop/src/test/electron-mock.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ function createWebContentsMock() {
241241
setPermissionRequestHandler: vi.fn(),
242242
setPermissionCheckHandler: vi.fn(),
243243
setUserAgent: vi.fn(),
244-
webRequest: { onBeforeRequest: vi.fn() },
244+
webRequest: { onBeforeRequest: vi.fn(), onHeadersReceived: vi.fn() },
245245
on: vi.fn(),
246246
},
247247
}

0 commit comments

Comments
 (0)