Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions apps/desktop/e2e/session-cookies.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { createServer, type Server } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { type ElectronApplication, _electron as electron, expect, test } from '@playwright/test'
import type { SimDesktopApi } from '@sim/desktop-bridge'

const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
const SCOPE = 'session-cookies-fixture'

/**
* Electron drops every expiry-less cookie on quit, so a site that keeps its
* login in a session cookie signed the user out of the built-in browser on
* every restart. This drives a real quit and relaunch against one profile.
*/
test.describe('built-in browser session cookies', () => {
let server: Server
let origin: string
let site: string
let userData: string
let app: ElectronApplication | undefined
let serial = 0
let lastCookieHeader: string | undefined
const calls = new Map<
string,
{ chatId: string; toolName: string; args: Record<string, unknown> }
>()

test.beforeAll(async () => {
server = createServer(async (request, response) => {
const path = new URL(request.url ?? '/', 'http://localhost').pathname
if (path === '/api/auth/get-session') {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(
JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } })
)
return
}
if (path === '/api/desktop/tool/authorize') {
let body = ''
for await (const chunk of request) body += chunk.toString()
const call = calls.get(JSON.parse(body).toolCallId)
response.writeHead(call ? 200 : 403, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(call ?? {}))
return
}
if (path.startsWith('/api/')) {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end('{}')
return
}
if (path === '/sign-in') {
// A login redirect that sets a short-lived cookie the next hop deletes.
response.writeHead(302, {
'Set-Cookie': 'oauth_state=pending; HttpOnly; Path=/',
Location: '/signed-in',
})
response.end()
return
}
if (path === '/signed-in') {
response.writeHead(200, {
'Content-Type': 'text/html',
'Set-Cookie': [
'oauth_state=; Path=/; Max-Age=0',
'login=fixture; HttpOnly; SameSite=Lax; Path=/',
'remember=1; Path=/; Max-Age=3600',
],
})
response.end('<!doctype html><title>Signed in</title>')
return
}
if (path === '/account') {
lastCookieHeader = request.headers.cookie ?? ''
response.writeHead(200, { 'Content-Type': 'text/html' })
response.end('<!doctype html><title>Account</title>')
return
}
if (request.headers.host?.startsWith('localhost')) {
// Favicon and other stray site requests must not set the app session cookie on the site.
response.writeHead(404)
response.end()
return
}
response.writeHead(200, {
'Content-Type': 'text/html',
'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/',
})
response.end('<!doctype html><title>Sim fixture</title><h1>Session cookie fixture</h1>')
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
origin = `http://127.0.0.1:${address.port}`
/** Pages outside the app origin browse in the built-in browser's own partition. */
site = `http://localhost:${address.port}`
})

test.beforeEach(() => {
userData = mkdtempSync(join(tmpdir(), 'sim-session-cookies-e2e-'))
})

test.afterEach(async () => {
await app?.close()
app = undefined
rmSync(userData, { recursive: true, force: true })
calls.clear()
})

test.afterAll(async () => {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
)
})

async function launch(): Promise<ElectronApplication> {
const launched = await electron.launch({
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
cwd: DESKTOP_DIR,
env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData },
})
const host = await launched.firstWindow()
await expect(host.getByRole('heading')).toHaveText('Session cookie fixture')
await host.evaluate(async (scope) => {
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
await api.browserAgent.activateScope(scope)
const updateBounds = () =>
api.browserAgent.setPanelBounds(
{ x: 0, y: 80, width: innerWidth, height: innerHeight - 80 },
null,
scope
)
updateBounds()
setInterval(updateBounds, 200)
}, SCOPE)
return launched
}

async function navigate(target: ElectronApplication, url: string) {
const id = `fixture-${++serial}`
calls.set(id, { chatId: SCOPE, toolName: 'browser_open_url', args: { url } })
const host = await target.firstWindow()
const result = await host.evaluate(
async ({ id, url, scope }) => {
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
return api.browserAgent.executeTool(id, 'browser_open_url', { url }, scope)
},
{ id, url, scope: SCOPE }
)
expect(result.ok, result.error).toBe(true)
}

async function cookiesSentToSite(target: ElectronApplication): Promise<string[]> {
lastCookieHeader = undefined
await navigate(target, `${site}/account`)
await expect.poll(() => lastCookieHeader).not.toBeUndefined()
return (lastCookieHeader ?? '')
.split(';')
.map((pair) => pair.trim())
.filter(Boolean)
.sort()
}

test('keeps a session-cookie login across a restart without reviving deleted cookies', async () => {
app = await launch()
await navigate(app, `${site}/sign-in`)
expect(await cookiesSentToSite(app)).toEqual(['login=fixture', 'remember=1'])

await app.evaluate(({ session }) =>
session.fromPartition('persist:sim-browser-agent').cookies.flushStore()
)
await app.close()

app = await launch()
expect(await cookiesSentToSite(app)).toEqual(['login=fixture', 'remember=1'])
Comment thread
TheodoreSpeaks marked this conversation as resolved.
})
})
51 changes: 51 additions & 0 deletions apps/desktop/src/main/browser-agent/session-cookies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
SESSION_COOKIE_LIFETIME_SECONDS,
withSessionCookieLifetime,
withSessionCookieMaxAge,
} from '@/main/browser-agent/session-cookies'

const NOW = 1_800_000_000
const MAX_AGE = `Max-Age=${SESSION_COOKIE_LIFETIME_SECONDS}`

describe('withSessionCookieLifetime', () => {
it('gives an expiry-less cookie the bounded lifetime', () => {
expect(withSessionCookieLifetime({ url: 'https://example.com/', name: 'a' }, NOW)).toEqual({
url: 'https://example.com/',
name: 'a',
expirationDate: NOW + SESSION_COOKIE_LIFETIME_SECONDS,
})
})

it('leaves a cookie that already expires untouched', () => {
const cookie = { url: 'https://example.com/', name: 'a', expirationDate: NOW + 60 }
expect(withSessionCookieLifetime(cookie, NOW)).toBe(cookie)
})
})

describe('withSessionCookieMaxAge', () => {
it('adds Max-Age to a session cookie', () => {
expect(withSessionCookieMaxAge('sid=abc; Path=/; Secure; HttpOnly')).toBe(
`sid=abc; Path=/; Secure; HttpOnly; ${MAX_AGE}`
)
expect(withSessionCookieMaxAge('sid=abc')).toBe(`sid=abc; ${MAX_AGE}`)
})

it('leaves cookies with an expiry or a deletion untouched', () => {
for (const value of [
'sid=abc; Expires=Wed, 21 Oct 2030 07:28:00 GMT',
'sid=abc; max-age=60',
'sid=; Path=/; Max-Age=0',
'sid=abc;EXPIRES=Thu, 01 Jan 1970 00:00:00 GMT',
]) {
expect(withSessionCookieMaxAge(value)).toBe(value)
}
})

it('reads attributes only, never the cookie value', () => {
expect(withSessionCookieMaxAge('max-age=1')).toBe(`max-age=1; ${MAX_AGE}`)
})
})
60 changes: 60 additions & 0 deletions apps/desktop/src/main/browser-agent/session-cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { CookiesSetDetails, Session } from 'electron'

/**
* Session cookies in the browser partition get this lifetime instead of dying
* with the process.
*
* Electron hard-codes Chromium's `persist_session_cookies` and
* `restore_old_session_cookies` to false, so a `persist:` partition still drops
* every expiry-less cookie on quit — which signs the user out of any site that
* keeps its login in one, whether it was imported from Chrome or set here.
* Chrome keeps them across restarts under "continue where you left off"; this
* matches that, bounded. The site re-sending the cookie renews the window, so
* only a login left untouched for this long lapses.
*/
export const SESSION_COOKIE_LIFETIME_SECONDS = 30 * 24 * 60 * 60

/** Gives an expiry-less cookie the bounded lifetime; cookies that already expire are unchanged. */
export function withSessionCookieLifetime(
cookie: CookiesSetDetails,
nowSeconds: number
): CookiesSetDetails {
if (cookie.expirationDate !== undefined) return cookie
return { ...cookie, expirationDate: nowSeconds + SESSION_COOKIE_LIFETIME_SECONDS }
}

/**
* Adds `Max-Age` to a `Set-Cookie` value that has neither `Expires` nor
* `Max-Age`. Deletions carry one of the two, so they pass through untouched.
*/
export function withSessionCookieMaxAge(setCookie: string): string {
const attributes = setCookie.split(';').slice(1)
const expires = attributes.some((attribute) => {
const name = attribute.split('=', 1)[0].trim().toLowerCase()
return name === 'expires' || name === 'max-age'
})
return expires ? setCookie : `${setCookie}; Max-Age=${SESSION_COOKIE_LIFETIME_SECONDS}`
}

/**
* Makes session cookies a site sets over HTTP survive an app restart.
*
* The rewrite happens on the response headers, before Chromium stores the
* cookie, so it is created persistent in the same order as every other cookie
* write — a later deletion from the site still wins. Rewriting stored cookies
* after the fact would race exactly that: login redirects set and clear
* short-lived cookies milliseconds apart. Cookies set from page script
* (`document.cookie`) are not covered; they cannot be `HttpOnly`, so session
* logins rarely live in them.
*/
export function keepSessionCookiesAcrossRestarts(ses: Session): void {
ses.webRequest.onHeadersReceived((details, callback) => {
const headers = details.responseHeaders
const key = headers && Object.keys(headers).find((name) => name.toLowerCase() === 'set-cookie')
if (!headers || !key) {
callback({})
return
}
callback({ responseHeaders: { ...headers, [key]: headers[key].map(withSessionCookieMaxAge) } })
})
}
11 changes: 9 additions & 2 deletions apps/desktop/src/main/browser-agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ import {
registerAgentWebContents,
} from '@/main/browser-agent/registry'
import { handleBrowserRequest } from '@/main/browser-agent/request-policy'
import {
keepSessionCookiesAcrossRestarts,
withSessionCookieLifetime,
} from '@/main/browser-agent/session-cookies'
import { clearHostVerdictCache } from '@/main/browser-agent/url-guard'
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
Expand Down Expand Up @@ -1327,17 +1331,19 @@ export async function listAgentCookieSignals(): Promise<BrowserCookieSignal[]> {
* here and is counted rather than being quietly relaxed.
*
* Failures are per-cookie: one rejected cookie must not cost the user the
* rest. Nothing about a cookie is logged.
* rest. Nothing about a cookie is logged. Session cookies get the bounded
* lifetime from {@link withSessionCookieLifetime} so they survive a restart.
*/
export async function importAgentCookies(
cookies: CookiesSetDetails[]
): Promise<{ imported: number; failed: number }> {
const jar = electronSession.fromPartition(AGENT_PARTITION).cookies
const nowSeconds = Date.now() / 1000
let imported = 0
let failed = 0
for (const cookie of cookies) {
try {
await jar.set(cookie)
await jar.set(withSessionCookieLifetime(cookie, nowSeconds))
imported += 1
} catch {
failed += 1
Expand Down Expand Up @@ -1594,6 +1600,7 @@ const browserPermissions: BrowserPermissionHandlers = {
function configureAgentPartition(ses: Session): void {
if (configuredPartitions.has(ses)) return
configuredPartitions.add(ses)
keepSessionCookiesAcrossRestarts(ses)
ses.setPermissionRequestHandler(browserPermissions.request)
ses.setPermissionCheckHandler(browserPermissions.check)
ses.webRequest.onBeforeRequest((details, callback) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/test/electron-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ function createWebContentsMock() {
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setUserAgent: vi.fn(),
webRequest: { onBeforeRequest: vi.fn() },
webRequest: { onBeforeRequest: vi.fn(), onHeadersReceived: vi.fn() },
on: vi.fn(),
},
}
Expand Down
Loading
Loading