diff --git a/apps/desktop/e2e/session-cookies.spec.ts b/apps/desktop/e2e/session-cookies.spec.ts new file mode 100644 index 00000000000..7c85b99e63f --- /dev/null +++ b/apps/desktop/e2e/session-cookies.spec.ts @@ -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 } + >() + + 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('Signed in') + return + } + if (path === '/account') { + lastCookieHeader = request.headers.cookie ?? '' + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.end('Account') + 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('Sim fixture

Session cookie fixture

') + }) + await new Promise((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((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + }) + + async function launch(): Promise { + 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 { + 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']) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/session-cookies.test.ts b/apps/desktop/src/main/browser-agent/session-cookies.test.ts new file mode 100644 index 00000000000..c20381eb983 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/session-cookies.test.ts @@ -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}`) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/session-cookies.ts b/apps/desktop/src/main/browser-agent/session-cookies.ts new file mode 100644 index 00000000000..b46853d941d --- /dev/null +++ b/apps/desktop/src/main/browser-agent/session-cookies.ts @@ -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) } }) + }) +} diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index d1643b12dbe..1b7de802598 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -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' @@ -1327,17 +1331,19 @@ export async function listAgentCookieSignals(): Promise { * 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 @@ -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) => { diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index d929be7057f..822eab9e3ad 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -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(), }, } diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index baef5cd3202..0eb6a5ea160 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -22,7 +22,13 @@ import { scrollFadeAttributes, scrollFadeXClass, } from '@sim/emcn' -import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' +import { + AnimatePresence, + type AnimationPlaybackControls, + animate, + motion, + useReducedMotion, +} from 'framer-motion' import { Plus, X } from '../../icons' import { cn } from '../../lib/cn' import { Button } from '../button/button' @@ -32,6 +38,12 @@ import { TabStripAction } from './tab-strip-action' const DRAG_EDGE_ZONE = 40 const DRAG_SCROLL_SPEED = 8 const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const } +/** + * Fixed duration for bringing the active tab into view. Native smooth scrolling + * takes longer the further it travels, so a crowded strip crawled across dozens + * of tabs. + */ +const REVEAL_SCROLL_TRANSITION = { duration: 0.2, ease: TAB_TRANSITION.ease } /** * Width, not flex-basis: `flex-1` compiles to `flex: 1 1 0%`, and Tailwind emits @@ -508,6 +520,8 @@ export function TabStrip({ const dropTargetIndexRef = useRef(null) const autoScrollRafRef = useRef(null) const autoScrollDirectionRef = useRef(0) + const revealScrollRef = useRef(null) + const revealScrollLeftRef = useRef(0) const focusedTabRef = useRef<{ id: string element: HTMLButtonElement @@ -537,6 +551,11 @@ export function TabStrip({ setCanScrollRight(node.scrollLeft < maxScrollLeft - 1) }, []) + const stopRevealScroll = useCallback(() => { + revealScrollRef.current?.stop() + revealScrollRef.current = null + }, []) + const stopAutoScroll = useCallback(() => { if (autoScrollRafRef.current !== null) cancelAnimationFrame(autoScrollRafRef.current) autoScrollRafRef.current = null @@ -556,6 +575,9 @@ export function TabStrip({ const revealActiveTab = useCallback(() => { const node = scrollNodeRef.current if (!node || !activeRegularId) return + // Stopped before any early return, so a reveal still in flight toward a + // previous tab cannot scroll an already-visible active tab away. + stopRevealScroll() const element = Array.from(node.querySelectorAll('[data-tab-strip-item]')).find( (candidate) => candidate.dataset.tabStripItem === activeRegularId ) @@ -579,9 +601,20 @@ export function TabStrip({ // gradient at a scroll extreme, so no margin is needed to clear one. const nextLeft = Math.max(0, Math.min(maxScrollLeft, target)) if (Math.abs(nextLeft - node.scrollLeft) < 1) return - const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false - node.scrollTo({ left: nextLeft, behavior: reduceMotion ? 'auto' : 'smooth' }) - }, [activeRegularId, regularTabOrder]) + if (reduceMotion) { + node.scrollLeft = nextLeft + return + } + revealScrollRef.current = animate(node.scrollLeft, nextLeft, { + ...REVEAL_SCROLL_TRANSITION, + onUpdate: (left) => { + revealScrollLeftRef.current = left + node.scrollLeft = left + }, + }) + }, [activeRegularId, regularTabOrder, reduceMotion, stopRevealScroll]) + + useEffect(() => stopRevealScroll, [stopRevealScroll]) useLayoutEffect(() => { revealActiveTab() @@ -595,17 +628,25 @@ export function TabStrip({ revealActiveTab() } updateLayout() - node.addEventListener('scroll', updateOverflow, { passive: true }) + const handleScroll = () => { + updateOverflow() + // A position the reveal did not set means touch, the scrollbar, or the + // keyboard took over scrolling, so the reveal yields to it. + if (revealScrollRef.current && Math.abs(node.scrollLeft - revealScrollLeftRef.current) > 1) { + stopRevealScroll() + } + } + node.addEventListener('scroll', handleScroll, { passive: true }) if (typeof ResizeObserver === 'undefined') { - return () => node.removeEventListener('scroll', updateOverflow) + return () => node.removeEventListener('scroll', handleScroll) } const observer = new ResizeObserver(updateLayout) observer.observe(node) return () => { observer.disconnect() - node.removeEventListener('scroll', updateOverflow) + node.removeEventListener('scroll', handleScroll) } - }, [regularTabs.length, revealActiveTab, updateOverflow]) + }, [regularTabs.length, revealActiveTab, stopRevealScroll, updateOverflow]) useEffect(() => { const strip = stripRef.current @@ -621,13 +662,14 @@ export function TabStrip({ event.deltaY ) if (next === null) return + stopRevealScroll() node.scrollLeft = next updateOverflow() event.preventDefault() } strip.addEventListener('wheel', handleWheel, { passive: false }) return () => strip.removeEventListener('wheel', handleWheel) - }, [updateOverflow]) + }, [stopRevealScroll, updateOverflow]) const handleDragStart = useCallback( (event: ReactDragEvent, id: string) => { @@ -677,6 +719,7 @@ export function TabStrip({ stopAutoScroll() if (direction === 0) return autoScrollDirectionRef.current = direction + stopRevealScroll() const tick = () => { const before = node.scrollLeft node.scrollLeft += direction * DRAG_SCROLL_SPEED @@ -690,7 +733,7 @@ export function TabStrip({ } autoScrollRafRef.current = requestAnimationFrame(tick) }, - [stopAutoScroll, tabs, updateOverflow] + [stopAutoScroll, stopRevealScroll, tabs, updateOverflow] ) const handleDragOver = useCallback(