-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(desktop): keep built-in browser logins across restarts, faster tab reveal #8337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
f1772cf
fix(desktop): keep built-in browser logins across app restarts
TheodoreSpeaks 36965cb
test(desktop): cover session-cookie persistence with a relaunch e2e
TheodoreSpeaks 47ffe00
improvement(tab-strip): reveal the active tab in a fixed 200ms
TheodoreSpeaks 1a6a708
fix(tab-strip): stop an in-flight reveal on every retarget and on poi…
TheodoreSpeaks ee6c10e
fix(tab-strip): yield the reveal only to scrolling it did not cause
TheodoreSpeaks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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']) | ||
| }) | ||
| }) | ||
51 changes: 51 additions & 0 deletions
51
apps/desktop/src/main/browser-agent/session-cookies.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) } }) | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.